@bobfrankston/mailx 1.0.290 → 1.0.292
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
CHANGED
|
@@ -939,6 +939,9 @@ async function main() {
|
|
|
939
939
|
imapManager.on("syncComplete", (accountId) => {
|
|
940
940
|
handle.send({ _event: "syncComplete", type: "syncComplete", accountId });
|
|
941
941
|
});
|
|
942
|
+
imapManager.on("syncActionFailed", (accountId, action, uid, error) => {
|
|
943
|
+
handle.send({ _event: "syncActionFailed", type: "syncActionFailed", accountId, action, uid, error });
|
|
944
|
+
});
|
|
942
945
|
// Cloud-write/read failures from mailx-settings → push to UI as a banner so
|
|
943
946
|
// silent fall-back-to-local can no longer swallow Drive errors.
|
|
944
947
|
const { onCloudError } = await import("@bobfrankston/mailx-settings");
|
package/client/app.js
CHANGED
|
@@ -608,12 +608,12 @@ async function deleteSelectedMessages() {
|
|
|
608
608
|
if (selected.length === 1) {
|
|
609
609
|
lastDeleted = { ...selected[0], subject: "" };
|
|
610
610
|
if (statusSync)
|
|
611
|
-
statusSync.textContent = `
|
|
611
|
+
statusSync.textContent = `Trashed 1 message (syncing) — Ctrl+Z to undo`;
|
|
612
612
|
}
|
|
613
613
|
else {
|
|
614
614
|
lastDeleted = null;
|
|
615
615
|
if (statusSync)
|
|
616
|
-
statusSync.textContent = `
|
|
616
|
+
statusSync.textContent = `Trashed ${selected.length} messages (syncing)`;
|
|
617
617
|
}
|
|
618
618
|
if (undoTimeout)
|
|
619
619
|
clearTimeout(undoTimeout);
|
|
@@ -711,7 +711,7 @@ async function spamSelectedMessages() {
|
|
|
711
711
|
await markAsSpamMessages(accountId, uids);
|
|
712
712
|
}
|
|
713
713
|
if (statusSync)
|
|
714
|
-
statusSync.textContent = `
|
|
714
|
+
statusSync.textContent = `Spam: ${selected.length} queued — pending server sync`;
|
|
715
715
|
messageState.removeMessages(selected);
|
|
716
716
|
}
|
|
717
717
|
catch (e) {
|
|
@@ -1044,6 +1044,14 @@ onWsEvent((event) => {
|
|
|
1044
1044
|
statusSync.textContent = `Synced ${new Date().toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false })}`;
|
|
1045
1045
|
break;
|
|
1046
1046
|
}
|
|
1047
|
+
case "syncActionFailed": {
|
|
1048
|
+
// Surface sync failures (move/delete/flag not applied on server)
|
|
1049
|
+
// so the user knows local-first actions haven't propagated yet.
|
|
1050
|
+
const action = event.action === "move" ? "Move" : event.action === "delete" ? "Delete" : event.action;
|
|
1051
|
+
if (statusSync)
|
|
1052
|
+
statusSync.textContent = `Sync failed: ${action} — ${event.error}`;
|
|
1053
|
+
break;
|
|
1054
|
+
}
|
|
1047
1055
|
case "reload":
|
|
1048
1056
|
location.reload();
|
|
1049
1057
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.292",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@bobfrankston/iflow-node": "^0.1.7",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.325",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
"@bobfrankston/iflow-node": "^0.1.7",
|
|
87
87
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
88
88
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
89
|
-
"@bobfrankston/msger": "^0.1.
|
|
89
|
+
"@bobfrankston/msger": "^0.1.325",
|
|
90
90
|
"@capacitor/android": "^8.3.0",
|
|
91
91
|
"@capacitor/cli": "^8.3.0",
|
|
92
92
|
"@capacitor/core": "^8.3.0",
|
|
@@ -22,6 +22,7 @@ export interface ImapManagerEvents {
|
|
|
22
22
|
/** Fired after a message body has been written to the local store — lets
|
|
23
23
|
* the UI flip a row's "not-downloaded" indicator without re-rendering. */
|
|
24
24
|
bodyCached: (accountId: string, uid: number) => void;
|
|
25
|
+
syncActionFailed: (accountId: string, action: string, uid: number, error: string) => void;
|
|
25
26
|
}
|
|
26
27
|
export declare class ImapManager extends EventEmitter {
|
|
27
28
|
private configs;
|
|
@@ -2078,9 +2078,11 @@ export class ImapManager extends EventEmitter {
|
|
|
2078
2078
|
catch (e) {
|
|
2079
2079
|
console.error(` [sync] Failed action ${action.action} UID ${action.uid}: ${e.message}`);
|
|
2080
2080
|
this.db.failSyncAction(action.id, e.message);
|
|
2081
|
+
this.emit("syncActionFailed", accountId, action.action, action.uid, e.message);
|
|
2081
2082
|
if (action.attempts >= 5) {
|
|
2082
2083
|
console.error(` [sync] Giving up on action ${action.id} after 5 attempts`);
|
|
2083
2084
|
this.db.completeSyncAction(action.id);
|
|
2085
|
+
this.emit("syncActionFailed", accountId, action.action, action.uid, `Gave up after 5 attempts: ${e.message}`);
|
|
2084
2086
|
}
|
|
2085
2087
|
}
|
|
2086
2088
|
}
|
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
* Dropbox (removed 2026-04-06): Never implemented — placeholder only.
|
|
17
17
|
* Would need Dropbox OAuth app, token management, and Dropbox API v2 calls.
|
|
18
18
|
*/
|
|
19
|
-
/** Find the
|
|
19
|
+
/** Find the settings folder on GDrive, or create a flat "mailx" folder.
|
|
20
|
+
* Supports nested paths from config (e.g. "home/.mailx") by walking each
|
|
21
|
+
* segment. Falls back to the folderId in config if set, so a stale ID that
|
|
22
|
+
* points to the wrong folder gets corrected on next lookup. */
|
|
20
23
|
export declare function gDriveFindOrCreateFolder(): Promise<string | null>;
|
|
21
24
|
export type CloudProvider = "gdrive" | "google" | "local";
|
|
22
25
|
export interface CloudFile {
|
|
@@ -50,7 +50,10 @@ const GDRIVE_TOKEN_DIR = path.join(SETTINGS_DIR, "tokens", "gdrive");
|
|
|
50
50
|
// drive.file: app can only see files it created. Safe, publishable without security audit.
|
|
51
51
|
// All machines sharing the same OAuth client ID see the same files.
|
|
52
52
|
const GDRIVE_SCOPES = "https://www.googleapis.com/auth/drive.file";
|
|
53
|
-
|
|
53
|
+
/** Default folder name when config.path isn't set. The actual folder name
|
|
54
|
+
* is taken from pendingCloudConfig.path or readLocalConfig().sharedDir.path
|
|
55
|
+
* so each user can use their own convention (e.g. "home/.mailx"). */
|
|
56
|
+
const GDRIVE_FOLDER_NAME_DEFAULT = "mailx";
|
|
54
57
|
// ── Token helpers ──
|
|
55
58
|
async function getGoogleDriveToken() {
|
|
56
59
|
const creds = findGoogleCredentials();
|
|
@@ -99,39 +102,85 @@ async function getGoogleDriveToken() {
|
|
|
99
102
|
}
|
|
100
103
|
}
|
|
101
104
|
// ── Google Drive API (folder-ID based) ──
|
|
102
|
-
/** Find
|
|
105
|
+
/** Find a single folder by name, optionally inside a parent. */
|
|
106
|
+
async function gDriveFindFolder(token, name, parentId) {
|
|
107
|
+
let query = `name='${name}' and mimeType='application/vnd.google-apps.folder' and trashed=false`;
|
|
108
|
+
if (parentId)
|
|
109
|
+
query += ` and '${parentId}' in parents`;
|
|
110
|
+
const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id,name)`, {
|
|
111
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
112
|
+
});
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
console.error(` [cloud] gdrive folder search '${name}': ${res.status} ${res.statusText}`);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const data = await res.json();
|
|
118
|
+
return data.files?.[0]?.id || null;
|
|
119
|
+
}
|
|
120
|
+
/** Find the settings folder on GDrive, or create a flat "mailx" folder.
|
|
121
|
+
* Supports nested paths from config (e.g. "home/.mailx") by walking each
|
|
122
|
+
* segment. Falls back to the folderId in config if set, so a stale ID that
|
|
123
|
+
* points to the wrong folder gets corrected on next lookup. */
|
|
103
124
|
export async function gDriveFindOrCreateFolder() {
|
|
104
125
|
const token = await getGoogleDriveToken();
|
|
105
126
|
if (!token)
|
|
106
127
|
return null;
|
|
128
|
+
// Read path from config — supports nested like "home/.mailx"
|
|
129
|
+
let cfgEntry = null;
|
|
107
130
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
});
|
|
113
|
-
if (!res.ok) {
|
|
114
|
-
console.error(` [cloud] gdrive folder search: ${res.status} ${res.statusText}`);
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
const data = await res.json();
|
|
118
|
-
if (data.files?.[0]) {
|
|
119
|
-
console.log(` [cloud] Found existing '${GDRIVE_FOLDER_NAME}' folder: ${data.files[0].id}`);
|
|
120
|
-
return data.files[0].id;
|
|
131
|
+
const cfgPath = path.join(SETTINGS_DIR, "config.jsonc");
|
|
132
|
+
if (fs.existsSync(cfgPath)) {
|
|
133
|
+
const raw = fs.readFileSync(cfgPath, "utf-8").replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
134
|
+
cfgEntry = JSON.parse(raw).sharedDir;
|
|
121
135
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
136
|
+
}
|
|
137
|
+
catch { /* ignore */ }
|
|
138
|
+
const cfgPath = (typeof cfgEntry === "object" && cfgEntry?.path) ? cfgEntry.path : GDRIVE_FOLDER_NAME_DEFAULT;
|
|
139
|
+
const segments = cfgPath.split(/[/\\]/).filter(Boolean);
|
|
140
|
+
try {
|
|
141
|
+
// Walk nested path segments: "home/.mailx" → find "home", then ".mailx" inside it
|
|
142
|
+
let parentId;
|
|
143
|
+
for (let i = 0; i < segments.length; i++) {
|
|
144
|
+
const seg = segments[i];
|
|
145
|
+
const found = await gDriveFindFolder(token, seg, parentId);
|
|
146
|
+
if (found) {
|
|
147
|
+
console.log(` [cloud] Found folder '${seg}': ${found}${parentId ? ` (in ${parentId})` : ""}`);
|
|
148
|
+
parentId = found;
|
|
149
|
+
}
|
|
150
|
+
else if (i === segments.length - 1) {
|
|
151
|
+
// Last segment missing — create it (don't create intermediate folders)
|
|
152
|
+
const body = { name: seg, mimeType: "application/vnd.google-apps.folder" };
|
|
153
|
+
if (parentId)
|
|
154
|
+
body.parents = [parentId];
|
|
155
|
+
const createRes = await fetch("https://www.googleapis.com/drive/v3/files", {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
158
|
+
body: JSON.stringify(body),
|
|
159
|
+
});
|
|
160
|
+
if (!createRes.ok) {
|
|
161
|
+
console.error(` [cloud] gdrive folder create '${seg}': ${createRes.status}`);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const created = await createRes.json();
|
|
165
|
+
console.log(` [cloud] Created '${seg}' folder: ${created.id}`);
|
|
166
|
+
parentId = created.id;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
// Intermediate segment missing — can't create the whole tree
|
|
170
|
+
console.error(` [cloud] gdrive path '${cfgPath}': intermediate folder '${seg}' not found`);
|
|
171
|
+
// Fall back to flat search for the leaf name
|
|
172
|
+
const leafId = await gDriveFindFolder(token, segments[segments.length - 1]);
|
|
173
|
+
if (leafId) {
|
|
174
|
+
console.log(` [cloud] Fallback: found '${segments[segments.length - 1]}' at root: ${leafId}`);
|
|
175
|
+
return leafId;
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
131
179
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
180
|
+
if (parentId)
|
|
181
|
+
return parentId;
|
|
182
|
+
// Shouldn't reach here, but safety
|
|
183
|
+
return null;
|
|
135
184
|
}
|
|
136
185
|
catch (e) {
|
|
137
186
|
console.error(` [cloud] gdrive folder setup: ${e.message}`);
|