@bobfrankston/mailx 1.0.265 → 1.0.278
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/app.js +72 -14
- package/client/components/folder-tree.js +2 -2
- package/client/components/message-list.js +43 -3
- package/client/components/message-viewer.js +29 -6
- package/client/lib/api-client.js +2 -2
- package/client/lib/mailxapi.js +2 -2
- package/package.json +15 -15
- package/packages/mailx-core/index.d.ts +1 -0
- package/packages/mailx-core/index.js +1 -0
- package/packages/mailx-imap/index.d.ts +22 -1
- package/packages/mailx-imap/index.js +244 -42
- package/packages/mailx-service/index.d.ts +1 -1
- package/packages/mailx-service/index.js +44 -8
- package/packages/mailx-service/jsonrpc.js +1 -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.js +6 -0
- package/packages/mailx-store-web/web-service.js +13 -2
- package/packages/mailx-types/index.d.ts +4 -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":1344,"width":2151,"x":
|
|
1
|
+
{"height":1344,"width":2151,"x":284,"y":38}
|
package/client/app.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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
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";
|
|
9
9
|
import * as messageState from "./lib/message-state.js";
|
|
@@ -831,18 +831,54 @@ window.addEventListener("message", (e) => {
|
|
|
831
831
|
}
|
|
832
832
|
}
|
|
833
833
|
if (e.data?.type === "linkHover") {
|
|
834
|
-
|
|
835
|
-
if (
|
|
836
|
-
if (
|
|
837
|
-
|
|
838
|
-
|
|
834
|
+
let pop = document.getElementById("link-hover-popover");
|
|
835
|
+
if (!e.data.url) {
|
|
836
|
+
if (pop)
|
|
837
|
+
pop.style.display = "none";
|
|
838
|
+
}
|
|
839
|
+
else {
|
|
840
|
+
if (!pop) {
|
|
841
|
+
pop = document.createElement("div");
|
|
842
|
+
pop.id = "link-hover-popover";
|
|
843
|
+
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;";
|
|
844
|
+
document.body.appendChild(pop);
|
|
839
845
|
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
846
|
+
pop.textContent = e.data.url;
|
|
847
|
+
pop.style.display = "block";
|
|
848
|
+
// Locate the iframe whose contentWindow matches e.source so we can
|
|
849
|
+
// translate iframe-local rect coords into viewport coords.
|
|
850
|
+
let iframeRect = null;
|
|
851
|
+
for (const f of Array.from(document.querySelectorAll("iframe"))) {
|
|
852
|
+
if (f.contentWindow === e.source) {
|
|
853
|
+
iframeRect = f.getBoundingClientRect();
|
|
854
|
+
break;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
const r = e.data.rect;
|
|
858
|
+
if (iframeRect && r) {
|
|
859
|
+
const x = Math.max(4, Math.min(window.innerWidth - 528, iframeRect.left + r.left));
|
|
860
|
+
let y = iframeRect.top + r.bottom + 4;
|
|
861
|
+
// If it would clip bottom, flip above the link
|
|
862
|
+
if (y + 60 > window.innerHeight)
|
|
863
|
+
y = Math.max(4, iframeRect.top + r.top - 60);
|
|
864
|
+
pop.style.left = x + "px";
|
|
865
|
+
pop.style.top = y + "px";
|
|
843
866
|
}
|
|
844
867
|
}
|
|
845
868
|
}
|
|
869
|
+
if (e.data?.type === "previewKey" && typeof e.data.key === "string") {
|
|
870
|
+
// Re-dispatch as a real keydown on document so the hotkey handler
|
|
871
|
+
// below runs the same code path as a list-focused keypress. Used
|
|
872
|
+
// when focus is inside the sandboxed preview iframe — works on
|
|
873
|
+
// platforms where parent-side contentDocument listeners don't.
|
|
874
|
+
const ev = new KeyboardEvent("keydown", {
|
|
875
|
+
key: e.data.key, code: e.data.code || "",
|
|
876
|
+
ctrlKey: !!e.data.ctrlKey, shiftKey: !!e.data.shiftKey,
|
|
877
|
+
altKey: !!e.data.altKey, metaKey: !!e.data.metaKey,
|
|
878
|
+
bubbles: true, cancelable: true,
|
|
879
|
+
});
|
|
880
|
+
document.dispatchEvent(ev);
|
|
881
|
+
}
|
|
846
882
|
});
|
|
847
883
|
// ── Splitter drag ──
|
|
848
884
|
const splitter = document.getElementById("splitter-h");
|
|
@@ -954,6 +990,11 @@ onWsEvent((event) => {
|
|
|
954
990
|
case "reload":
|
|
955
991
|
location.reload();
|
|
956
992
|
break;
|
|
993
|
+
case "bodyCached":
|
|
994
|
+
// Prefetch (or on-demand fetch) downloaded a body — flip the
|
|
995
|
+
// "not-downloaded" indicator to the teal dot for any rows in view.
|
|
996
|
+
markBodiesCached(event.items || []);
|
|
997
|
+
break;
|
|
957
998
|
case "configChanged":
|
|
958
999
|
// A watched config file was modified — could be user edit via the
|
|
959
1000
|
// JSONC editor, a GDrive sync, or mailx itself saving (e.g.
|
|
@@ -967,6 +1008,22 @@ onWsEvent((event) => {
|
|
|
967
1008
|
}, 8000);
|
|
968
1009
|
}
|
|
969
1010
|
break;
|
|
1011
|
+
case "cloudError":
|
|
1012
|
+
// Cloud read/write failed (Google Drive auth/network/etc.). Show a
|
|
1013
|
+
// sticky banner so the user knows the change wasn't synced. When
|
|
1014
|
+
// error is null, the next successful op cleared it — hide it.
|
|
1015
|
+
if (event.error) {
|
|
1016
|
+
const where = event.filename ? ` (${event.op || "sync"} ${event.filename})` : "";
|
|
1017
|
+
showAlert(`Cloud sync error${where}: ${event.error}`, "cloud-error");
|
|
1018
|
+
}
|
|
1019
|
+
else {
|
|
1020
|
+
// Only hide if the visible banner is the cloud-error one
|
|
1021
|
+
if (alertBanner && alertBanner.dataset.key === "cloud-error") {
|
|
1022
|
+
alertBanner.hidden = true;
|
|
1023
|
+
dismissedAlerts.delete("cloud-error");
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
break;
|
|
970
1027
|
case "error":
|
|
971
1028
|
if (statusSync)
|
|
972
1029
|
statusSync.textContent = `Error: ${event.message}`;
|
|
@@ -1340,16 +1397,17 @@ optThreaded?.addEventListener("change", () => {
|
|
|
1340
1397
|
localStorage.setItem("mailx-threaded", String(optThreaded.checked));
|
|
1341
1398
|
reloadCurrentFolder();
|
|
1342
1399
|
});
|
|
1343
|
-
// Flagged-only filter
|
|
1400
|
+
// Flagged-only filter — keeps the CSS-level hiding for instant feedback on
|
|
1401
|
+
// the current page AND re-queries the folder so flagged messages that live
|
|
1402
|
+
// outside the currently-loaded page show up.
|
|
1344
1403
|
optFlagged?.addEventListener("change", () => {
|
|
1345
1404
|
const body = document.getElementById("ml-body");
|
|
1346
|
-
if (optFlagged.checked)
|
|
1405
|
+
if (optFlagged.checked)
|
|
1347
1406
|
body?.classList.add("flagged-only");
|
|
1348
|
-
|
|
1349
|
-
else {
|
|
1407
|
+
else
|
|
1350
1408
|
body?.classList.remove("flagged-only");
|
|
1351
|
-
}
|
|
1352
1409
|
localStorage.setItem("mailx-flagged", String(optFlagged.checked));
|
|
1410
|
+
reloadCurrentFolder();
|
|
1353
1411
|
});
|
|
1354
1412
|
// Folder counts toggle
|
|
1355
1413
|
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
|
|
@@ -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);
|
|
@@ -550,20 +550,22 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
550
550
|
// "Message was deleted from the server" — the service already dropped
|
|
551
551
|
// the local row. Remove it from the list so the UI advances to the next
|
|
552
552
|
// message instead of sitting on a stale error banner.
|
|
553
|
-
const
|
|
554
|
-
if (
|
|
553
|
+
const isNotFound = /deleted from the server|isNotFound|not found|Not Found|404/.test(err);
|
|
554
|
+
if (isNotFound) {
|
|
555
|
+
// Drop the stale row so the list auto-advances to the next message
|
|
556
|
+
// (or clears the viewer). Leaves the user a way back on mobile where
|
|
557
|
+
// the viewer takes the whole screen.
|
|
555
558
|
state.removeMessages([{ accountId, uid }]);
|
|
556
559
|
return;
|
|
557
560
|
}
|
|
558
|
-
|
|
559
|
-
if (!isNotFound && retryCount < 3) {
|
|
561
|
+
if (retryCount < 3) {
|
|
560
562
|
retryCount++;
|
|
561
563
|
bodyEl.innerHTML = `<div class="mv-empty">Loading failed: ${err} — retrying (${retryCount}/3)...</div>`;
|
|
562
564
|
setTimeout(() => { if (gen === showMessageGeneration)
|
|
563
565
|
showMessage(accountId, uid, folderId, specialUse, true); }, 3000);
|
|
564
566
|
}
|
|
565
567
|
else {
|
|
566
|
-
bodyEl.innerHTML = `<div class="mv-empty"
|
|
568
|
+
bodyEl.innerHTML = `<div class="mv-empty">Failed to load: ${err}</div>`;
|
|
567
569
|
}
|
|
568
570
|
}
|
|
569
571
|
}
|
|
@@ -658,7 +660,28 @@ ${csp}
|
|
|
658
660
|
}, true);
|
|
659
661
|
document.addEventListener("mouseover", function (e) {
|
|
660
662
|
var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
|
|
661
|
-
|
|
663
|
+
if (a) {
|
|
664
|
+
var r = a.getBoundingClientRect();
|
|
665
|
+
window.parent.postMessage({ type: "linkHover", url: a.href, rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom } }, "*");
|
|
666
|
+
} else {
|
|
667
|
+
window.parent.postMessage({ type: "linkHover", url: "" }, "*");
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
// Key forwarding — Delete, Ctrl+D, arrow keys, etc. need to reach app.ts
|
|
671
|
+
// even when focus is inside the sandboxed iframe. Parent-side
|
|
672
|
+
// contentDocument listeners (see installPreviewControls) work on
|
|
673
|
+
// desktop WebView2 but not Android WebView, so we post every keydown
|
|
674
|
+
// that isn't plain typing.
|
|
675
|
+
document.addEventListener("keydown", function (e) {
|
|
676
|
+
var t = e.target;
|
|
677
|
+
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
|
|
678
|
+
// Zoom keys handled by parent-side installPreviewControls; don't double-send.
|
|
679
|
+
if (e.ctrlKey && (e.key === "=" || e.key === "+" || e.key === "-" || e.key === "0")) return;
|
|
680
|
+
window.parent.postMessage({
|
|
681
|
+
type: "previewKey",
|
|
682
|
+
key: e.key, code: e.code,
|
|
683
|
+
ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,
|
|
684
|
+
}, "*");
|
|
662
685
|
});
|
|
663
686
|
})();
|
|
664
687
|
</script>
|
package/client/lib/api-client.js
CHANGED
|
@@ -33,9 +33,9 @@ export function getAccounts() {
|
|
|
33
33
|
export function getFolders(accountId) {
|
|
34
34
|
return ipc().getFolders(accountId);
|
|
35
35
|
}
|
|
36
|
-
export function getMessages(accountId, folderId, page = 1, pageSize = 50) {
|
|
36
|
+
export function getMessages(accountId, folderId, page = 1, pageSize = 50, flaggedOnly = false) {
|
|
37
37
|
abortMessageListRequests();
|
|
38
|
-
return ipc().getMessages(accountId, folderId, page, pageSize);
|
|
38
|
+
return ipc().getMessages(accountId, folderId, page, pageSize, undefined, undefined, undefined, flaggedOnly);
|
|
39
39
|
}
|
|
40
40
|
export function getUnifiedInbox(page = 1, pageSize = 50) {
|
|
41
41
|
abortMessageListRequests();
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
getFolders: function(accountId) { return callNode("getFolders", { accountId: accountId }); },
|
|
66
66
|
|
|
67
67
|
// Messages
|
|
68
|
-
getMessages: function(accountId, folderId, page, pageSize) {
|
|
69
|
-
return callNode("getMessages", { accountId: accountId, folderId: folderId, page: page, pageSize: pageSize });
|
|
68
|
+
getMessages: function(accountId, folderId, page, pageSize, sort, sortDir, search, flaggedOnly) {
|
|
69
|
+
return callNode("getMessages", { accountId: accountId, folderId: folderId, page: page, pageSize: pageSize, sort: sort, sortDir: sortDir, search: search, flaggedOnly: flaggedOnly });
|
|
70
70
|
},
|
|
71
71
|
getUnifiedInbox: function(page, pageSize) {
|
|
72
72
|
return callNode("getUnifiedInbox", { page: page, pageSize: pageSize });
|