@bobfrankston/mailx 1.0.114 → 1.0.116
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/app.js +6 -1
- package/client/components/folder-tree.js +19 -0
- package/package.json +2 -2
- package/packages/mailx-api/index.js +26 -5
- package/packages/mailx-service/index.js +4 -1
- package/packages/mailx-settings/cloud.js +36 -15
- package/packages/mailx-settings/index.d.ts +3 -0
- package/packages/mailx-settings/index.js +36 -3
package/client/app.js
CHANGED
|
@@ -839,9 +839,14 @@ const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
|
|
|
839
839
|
fetch("/api/version").then(r => r.json()).then(d => {
|
|
840
840
|
const el = document.getElementById("app-version");
|
|
841
841
|
const storage = d.storage || {};
|
|
842
|
-
const storageLabel = storage.provider && storage.provider !== "local"
|
|
842
|
+
const storageLabel = storage.provider && storage.provider !== "local"
|
|
843
|
+
? ` [${storage.provider}${storage.mode === "api" ? " API" : ""}]`
|
|
844
|
+
: "";
|
|
843
845
|
if (el)
|
|
844
846
|
el.textContent = `mailx v${d.version}${storageLabel}${isApp ? "" : " [browser]"}`;
|
|
847
|
+
if (storage.cloudError) {
|
|
848
|
+
showAlert(`Cloud storage error: ${storage.cloudError}`, "cloud-error");
|
|
849
|
+
}
|
|
845
850
|
}).catch(async () => {
|
|
846
851
|
// Server not running — try to start it if we're in the app
|
|
847
852
|
const startupStatus = document.getElementById("startup-status");
|
|
@@ -322,6 +322,7 @@ async function loadFolderTree(container) {
|
|
|
322
322
|
if (mainBody) {
|
|
323
323
|
mainBody.innerHTML = `<div style="padding:2rem;line-height:1.8;max-width:500px">
|
|
324
324
|
<h2 style="margin-bottom:1rem">Welcome to mailx</h2>
|
|
325
|
+
<div id="setup-cloud-status"></div>
|
|
325
326
|
<p>Add your email account to get started.</p>
|
|
326
327
|
<form id="setup-form" style="margin-top:1rem">
|
|
327
328
|
<label style="display:block;margin-bottom:0.5rem">
|
|
@@ -384,6 +385,24 @@ async function loadFolderTree(container) {
|
|
|
384
385
|
statusEl.textContent = `Error: ${err.message}`;
|
|
385
386
|
}
|
|
386
387
|
});
|
|
388
|
+
// Show cloud storage status in setup form
|
|
389
|
+
fetch("/api/version").then(r => r.json()).then(d => {
|
|
390
|
+
const cloudEl = document.getElementById("setup-cloud-status");
|
|
391
|
+
if (!cloudEl)
|
|
392
|
+
return;
|
|
393
|
+
const s = d.storage || {};
|
|
394
|
+
if (s.cloudError) {
|
|
395
|
+
cloudEl.innerHTML = `<div style="padding:0.75rem;margin-bottom:1rem;background:#5c1a1a;color:#fca;border:1px solid #a33;border-radius:4px">
|
|
396
|
+
<strong>Cloud storage unavailable:</strong> ${s.cloudError}<br>
|
|
397
|
+
<span style="font-size:0.85rem">Settings on ${s.provider || "cloud"} cannot be read. Add an account below to initialize cloud storage.</span>
|
|
398
|
+
</div>`;
|
|
399
|
+
}
|
|
400
|
+
else if (s.mode === "api") {
|
|
401
|
+
cloudEl.innerHTML = `<div style="padding:0.5rem;margin-bottom:1rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;color:var(--color-text-muted)">
|
|
402
|
+
Using ${s.provider} API (no local mount)
|
|
403
|
+
</div>`;
|
|
404
|
+
}
|
|
405
|
+
}).catch(() => { });
|
|
387
406
|
}
|
|
388
407
|
// Dismiss startup overlay
|
|
389
408
|
const overlay = document.getElementById("startup-overlay");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.116",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"postinstall": "node launcher/builder/postinstall.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@bobfrankston/iflow": "^1.0.
|
|
23
|
+
"@bobfrankston/iflow": "^1.0.46",
|
|
24
24
|
"@bobfrankston/miscinfo": "^1.0.7",
|
|
25
25
|
"@bobfrankston/oauthsupport": "^1.0.20",
|
|
26
26
|
"@bobfrankston/rust-builder": "^0.1.3",
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { Router } from "express";
|
|
6
6
|
import { MailxService } from "@bobfrankston/mailx-service";
|
|
7
|
-
import { loadAccounts, saveAccounts, initLocalConfig } from "@bobfrankston/mailx-settings";
|
|
7
|
+
import { loadAccounts, loadAccountsAsync, saveAccounts, initLocalConfig, initCloudConfig, loadSettings } from "@bobfrankston/mailx-settings";
|
|
8
8
|
export function createApiRouter(db, imapManager) {
|
|
9
9
|
const svc = new MailxService(db, imapManager);
|
|
10
10
|
const router = Router();
|
|
@@ -116,13 +116,20 @@ export function createApiRouter(db, imapManager) {
|
|
|
116
116
|
}
|
|
117
117
|
// Ensure ~/.mailx exists
|
|
118
118
|
initLocalConfig();
|
|
119
|
+
// Default to gdrive for Gmail users (creates config.jsonc with gdrive provider)
|
|
120
|
+
const domain = email.split("@")[1]?.toLowerCase() || "";
|
|
121
|
+
if (["gmail.com", "googlemail.com"].includes(domain)) {
|
|
122
|
+
initCloudConfig("gdrive");
|
|
123
|
+
}
|
|
119
124
|
// Build account config (normalizeAccount handles provider detection)
|
|
120
125
|
const account = { email, name: name || email.split("@")[0] };
|
|
121
126
|
if (password)
|
|
122
127
|
account.password = password;
|
|
123
|
-
// Load existing accounts
|
|
124
|
-
|
|
125
|
-
|
|
128
|
+
// Load existing accounts — try cloud API in case home/.mailx already exists on Drive
|
|
129
|
+
let accounts = loadAccounts();
|
|
130
|
+
if (accounts.length === 0) {
|
|
131
|
+
accounts = await loadAccountsAsync();
|
|
132
|
+
}
|
|
126
133
|
const id = domain.split(".")[0] || "account";
|
|
127
134
|
if (accounts.some((a) => a.email === email)) {
|
|
128
135
|
res.json({ ok: false, error: "Account already exists" });
|
|
@@ -131,7 +138,21 @@ export function createApiRouter(db, imapManager) {
|
|
|
131
138
|
account.id = id;
|
|
132
139
|
accounts.push(account);
|
|
133
140
|
saveAccounts(accounts);
|
|
134
|
-
|
|
141
|
+
// Reload settings and register the new account in DB + IMAP so it works immediately
|
|
142
|
+
const settings = loadSettings();
|
|
143
|
+
const normalized = settings.accounts.find(a => a.id === id);
|
|
144
|
+
if (normalized) {
|
|
145
|
+
try {
|
|
146
|
+
await imapManager.addAccount(normalized);
|
|
147
|
+
console.log(` Account added: ${normalized.name} (${normalized.id})`);
|
|
148
|
+
// Start syncing in background
|
|
149
|
+
imapManager.syncAll().catch(() => { });
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
console.error(` Account setup IMAP error: ${e.message}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
res.json({ ok: true, message: "Account added and syncing." });
|
|
135
156
|
}
|
|
136
157
|
catch (e) {
|
|
137
158
|
res.status(500).json({ error: e.message });
|
|
@@ -75,7 +75,10 @@ export class MailxService {
|
|
|
75
75
|
hasRemoteContent: false, remoteAllowed: false, attachments: [], emlPath: "", deliveredTo: "", returnPath: "", listUnsubscribe: ""
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
|
-
if (raw) {
|
|
78
|
+
if (!raw) {
|
|
79
|
+
bodyText = "[Message body not available — not cached locally and IMAP fetch failed. Try again or re-sync the folder.]";
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
79
82
|
const parsed = await simpleParser(raw);
|
|
80
83
|
bodyHtml = parsed.html || "";
|
|
81
84
|
bodyText = parsed.text || "";
|
|
@@ -20,14 +20,20 @@ function findGoogleCredentials() {
|
|
|
20
20
|
const local = path.join(SETTINGS_DIR, "google-credentials.json");
|
|
21
21
|
if (fs.existsSync(local))
|
|
22
22
|
return local;
|
|
23
|
-
// Try to find iflow's credentials
|
|
23
|
+
// Try to find iflow's credentials via import.meta.resolve or node_modules walk
|
|
24
24
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
for (
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
// Walk up from this package to find iflow in node_modules
|
|
26
|
+
let dir = import.meta.dirname;
|
|
27
|
+
for (let i = 0; i < 5; i++) {
|
|
28
|
+
for (const name of ["iflow-credentials.json", "credentials.json"]) {
|
|
29
|
+
const p = path.join(dir, "node_modules", "@bobfrankston", "iflow", name);
|
|
30
|
+
if (fs.existsSync(p))
|
|
31
|
+
return p;
|
|
32
|
+
}
|
|
33
|
+
const parent = path.dirname(dir);
|
|
34
|
+
if (parent === dir)
|
|
35
|
+
break;
|
|
36
|
+
dir = parent;
|
|
31
37
|
}
|
|
32
38
|
}
|
|
33
39
|
catch { /* iflow not installed */ }
|
|
@@ -55,8 +61,10 @@ async function getMicrosoftToken() {
|
|
|
55
61
|
}
|
|
56
62
|
async function getGoogleDriveToken() {
|
|
57
63
|
const creds = findGoogleCredentials();
|
|
58
|
-
if (!creds)
|
|
64
|
+
if (!creds) {
|
|
65
|
+
console.error(" [cloud] No Google credentials found (checked ~/.mailx/google-credentials.json and iflow package)");
|
|
59
66
|
return null;
|
|
67
|
+
}
|
|
60
68
|
try {
|
|
61
69
|
const token = await authenticateOAuth(creds, {
|
|
62
70
|
scope: GDRIVE_SCOPES,
|
|
@@ -154,8 +162,10 @@ async function gDriveFind(fileName, parentName) {
|
|
|
154
162
|
}
|
|
155
163
|
async function gDriveRead(filePath) {
|
|
156
164
|
const token = await getGoogleDriveToken();
|
|
157
|
-
if (!token)
|
|
165
|
+
if (!token) {
|
|
166
|
+
console.error(` [cloud] gdrive read ${filePath}: no token`);
|
|
158
167
|
return null;
|
|
168
|
+
}
|
|
159
169
|
try {
|
|
160
170
|
// Parse path: "home/.mailx/settings.jsonc" → find by folder structure
|
|
161
171
|
const parts = filePath.split("/");
|
|
@@ -169,11 +179,15 @@ async function gDriveRead(filePath) {
|
|
|
169
179
|
const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
|
|
170
180
|
headers: { Authorization: `Bearer ${token}` },
|
|
171
181
|
});
|
|
172
|
-
if (!res.ok)
|
|
182
|
+
if (!res.ok) {
|
|
183
|
+
console.error(` [cloud] gdrive folder lookup '${folder}': ${res.status} ${res.statusText}`);
|
|
173
184
|
return null;
|
|
185
|
+
}
|
|
174
186
|
const data = await res.json();
|
|
175
|
-
if (!data.files?.[0])
|
|
187
|
+
if (!data.files?.[0]) {
|
|
188
|
+
console.error(` [cloud] gdrive folder '${folder}' not found (drive.file scope can only see app-created files)`);
|
|
176
189
|
return null;
|
|
190
|
+
}
|
|
177
191
|
parentId = data.files[0].id;
|
|
178
192
|
}
|
|
179
193
|
// Find the file
|
|
@@ -183,21 +197,28 @@ async function gDriveRead(filePath) {
|
|
|
183
197
|
const fileRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
|
|
184
198
|
headers: { Authorization: `Bearer ${token}` },
|
|
185
199
|
});
|
|
186
|
-
if (!fileRes.ok)
|
|
200
|
+
if (!fileRes.ok) {
|
|
201
|
+
console.error(` [cloud] gdrive file lookup '${fileName}': ${fileRes.status} ${fileRes.statusText}`);
|
|
187
202
|
return null;
|
|
203
|
+
}
|
|
188
204
|
const fileData = await fileRes.json();
|
|
189
205
|
const fileId = fileData.files?.[0]?.id;
|
|
190
|
-
if (!fileId)
|
|
206
|
+
if (!fileId) {
|
|
207
|
+
console.error(` [cloud] gdrive file '${fileName}' not found in ${parts.join("/")}`);
|
|
191
208
|
return null;
|
|
209
|
+
}
|
|
192
210
|
// Download content
|
|
193
211
|
const contentRes = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, {
|
|
194
212
|
headers: { Authorization: `Bearer ${token}` },
|
|
195
213
|
});
|
|
196
|
-
if (!contentRes.ok)
|
|
214
|
+
if (!contentRes.ok) {
|
|
215
|
+
console.error(` [cloud] gdrive download ${fileId}: ${contentRes.status} ${contentRes.statusText}`);
|
|
197
216
|
return null;
|
|
217
|
+
}
|
|
198
218
|
return await contentRes.text();
|
|
199
219
|
}
|
|
200
|
-
catch {
|
|
220
|
+
catch (e) {
|
|
221
|
+
console.error(` [cloud] gdrive read ${filePath}: ${e.message}`);
|
|
201
222
|
return null;
|
|
202
223
|
}
|
|
203
224
|
}
|
|
@@ -28,6 +28,7 @@ export declare function isCloudMode(): boolean;
|
|
|
28
28
|
export declare function getStorageInfo(): {
|
|
29
29
|
provider: string;
|
|
30
30
|
mode: "mount" | "api" | "local";
|
|
31
|
+
cloudError?: string;
|
|
31
32
|
};
|
|
32
33
|
declare const DEFAULT_PREFERENCES: {
|
|
33
34
|
ui: {
|
|
@@ -88,6 +89,8 @@ export declare function getConfigDir(): string;
|
|
|
88
89
|
export { getSharedDir };
|
|
89
90
|
/** Initialize local config if it doesn't exist */
|
|
90
91
|
export declare function initLocalConfig(sharedDir?: string, storePath?: string): void;
|
|
92
|
+
/** Initialize config with a cloud provider (e.g. gdrive for Gmail users) */
|
|
93
|
+
export declare function initCloudConfig(provider: "gdrive" | "onedrive" | "dropbox", cloudPath?: string): void;
|
|
91
94
|
declare const DEFAULT_SETTINGS: MailxSettings;
|
|
92
95
|
/** Get historyDays for an account: per-account override > system override > shared default */
|
|
93
96
|
export declare function getHistoryDays(accountId?: string): number;
|
|
@@ -92,6 +92,8 @@ function resolveProvider(cfg) {
|
|
|
92
92
|
}
|
|
93
93
|
/** Pending cloud config for API fallback (set when mount not found) */
|
|
94
94
|
let pendingCloudConfig = null;
|
|
95
|
+
/** Last cloud API error (for UI display) */
|
|
96
|
+
let lastCloudError = null;
|
|
95
97
|
function resolveSharedEntry(entry) {
|
|
96
98
|
if (typeof entry === "string") {
|
|
97
99
|
const p = resolvePath(entry);
|
|
@@ -127,12 +129,15 @@ export async function cloudRead(filename) {
|
|
|
127
129
|
if (!pendingCloudConfig)
|
|
128
130
|
return null;
|
|
129
131
|
const provider = getCloudProvider(pendingCloudConfig.provider);
|
|
130
|
-
if (!provider)
|
|
132
|
+
if (!provider) {
|
|
133
|
+
lastCloudError = `No cloud provider for ${pendingCloudConfig.provider}`;
|
|
131
134
|
return null;
|
|
135
|
+
}
|
|
132
136
|
const cloudPath = `${pendingCloudConfig.path}/${filename}`;
|
|
133
137
|
console.log(` [cloud] Reading ${cloudPath} via ${pendingCloudConfig.provider} API...`);
|
|
134
138
|
const content = await provider.read(cloudPath);
|
|
135
139
|
if (content) {
|
|
140
|
+
lastCloudError = null;
|
|
136
141
|
// Cache locally
|
|
137
142
|
try {
|
|
138
143
|
fs.mkdirSync(LOCAL_DIR, { recursive: true });
|
|
@@ -140,6 +145,9 @@ export async function cloudRead(filename) {
|
|
|
140
145
|
}
|
|
141
146
|
catch { /* ignore cache write failure */ }
|
|
142
147
|
}
|
|
148
|
+
else {
|
|
149
|
+
lastCloudError = `Could not read ${filename} from ${pendingCloudConfig.provider} (check credentials and drive.file scope)`;
|
|
150
|
+
}
|
|
143
151
|
return content;
|
|
144
152
|
}
|
|
145
153
|
/** Write a file via cloud API */
|
|
@@ -177,7 +185,7 @@ export function getStorageInfo() {
|
|
|
177
185
|
const name = pendingCloudConfig.provider === "onedrive" ? "OneDrive" :
|
|
178
186
|
(pendingCloudConfig.provider === "gdrive" || pendingCloudConfig.provider === "google") ? "Google Drive" :
|
|
179
187
|
pendingCloudConfig.provider === "dropbox" ? "Dropbox" : pendingCloudConfig.provider;
|
|
180
|
-
return { provider: name, mode: "api" };
|
|
188
|
+
return { provider: name, mode: "api", cloudError: lastCloudError || undefined };
|
|
181
189
|
}
|
|
182
190
|
}
|
|
183
191
|
return { provider: "local", mode: "local" };
|
|
@@ -234,7 +242,7 @@ function loadFile(filename, defaults) {
|
|
|
234
242
|
}
|
|
235
243
|
return { ...defaults, ...data };
|
|
236
244
|
}
|
|
237
|
-
/** Save a config file to the shared directory */
|
|
245
|
+
/** Save a config file to the shared directory (and cloud API if active) */
|
|
238
246
|
function saveFile(filename, data) {
|
|
239
247
|
const sharedDir = getSharedDir();
|
|
240
248
|
atomicWrite(path.join(sharedDir, filename), data);
|
|
@@ -245,6 +253,15 @@ function saveFile(filename, data) {
|
|
|
245
253
|
}
|
|
246
254
|
catch { /* ignore */ }
|
|
247
255
|
}
|
|
256
|
+
// Write to cloud API if filesystem mount unavailable (creates app-owned file for drive.file scope)
|
|
257
|
+
if (pendingCloudConfig) {
|
|
258
|
+
cloudWrite(filename, JSON.stringify(data, null, 2)).then(ok => {
|
|
259
|
+
if (ok)
|
|
260
|
+
console.log(` [cloud] Saved ${filename} via ${pendingCloudConfig.provider} API`);
|
|
261
|
+
else
|
|
262
|
+
console.error(` [cloud] Failed to save ${filename} via ${pendingCloudConfig.provider} API`);
|
|
263
|
+
}).catch(() => { });
|
|
264
|
+
}
|
|
248
265
|
}
|
|
249
266
|
const PROVIDERS = {
|
|
250
267
|
"gmail.com": {
|
|
@@ -571,6 +588,22 @@ export function initLocalConfig(sharedDir, storePath) {
|
|
|
571
588
|
fs.mkdirSync(LOCAL_DIR, { recursive: true });
|
|
572
589
|
atomicWrite(LOCAL_CONFIG_PATH, config);
|
|
573
590
|
}
|
|
591
|
+
/** Initialize config with a cloud provider (e.g. gdrive for Gmail users) */
|
|
592
|
+
export function initCloudConfig(provider, cloudPath = "home/.mailx") {
|
|
593
|
+
const existing = readLocalConfig();
|
|
594
|
+
if (existing.sharedDir)
|
|
595
|
+
return; // Already configured
|
|
596
|
+
const config = {
|
|
597
|
+
...existing,
|
|
598
|
+
sharedDir: { provider, path: cloudPath },
|
|
599
|
+
storePath: existing.storePath || DEFAULT_STORE_PATH,
|
|
600
|
+
};
|
|
601
|
+
fs.mkdirSync(LOCAL_DIR, { recursive: true });
|
|
602
|
+
atomicWrite(LOCAL_CONFIG_PATH, config);
|
|
603
|
+
// Set up cloud API fallback immediately
|
|
604
|
+
pendingCloudConfig = { provider, path: cloudPath };
|
|
605
|
+
console.log(` Initialized cloud config: ${provider} → ${cloudPath}`);
|
|
606
|
+
}
|
|
574
607
|
const DEFAULT_SETTINGS = {
|
|
575
608
|
accounts: [],
|
|
576
609
|
ui: DEFAULT_PREFERENCES.ui,
|