@bobfrankston/mailx 1.0.264 → 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
|
@@ -22,8 +22,8 @@ export type CloudProvider = "gdrive" | "google" | "local";
|
|
|
22
22
|
export interface CloudFile {
|
|
23
23
|
/** Read a file. For gdrive, path is just the filename (folder ID is implicit). */
|
|
24
24
|
read(filePath: string): Promise<string | null>;
|
|
25
|
-
/** Write a file. For gdrive, path is just the filename. */
|
|
26
|
-
write(filePath: string, content: string): Promise<
|
|
25
|
+
/** Write a file. For gdrive, path is just the filename. Throws on failure with a descriptive error. */
|
|
26
|
+
write(filePath: string, content: string): Promise<void>;
|
|
27
27
|
/** Check if a file exists. */
|
|
28
28
|
exists(filePath: string): Promise<boolean>;
|
|
29
29
|
}
|
|
@@ -32,4 +32,13 @@ export interface CloudFile {
|
|
|
32
32
|
* Files are stored flat in the folder (no subdirectory navigation).
|
|
33
33
|
*/
|
|
34
34
|
export declare function getCloudProvider(provider: string, folderId?: string): CloudFile | null;
|
|
35
|
+
/** Fetch the authenticated Google user's profile (name + email) via the People API.
|
|
36
|
+
* Caller must supply an OAuth access token whose scopes include `contacts.readonly`
|
|
37
|
+
* or `userinfo.profile`. The Drive token (drive.file) does NOT have the right scope
|
|
38
|
+
* on its own — use the Gmail/IMAP OAuth token instead (mailx-imap's
|
|
39
|
+
* ImapManager.getOAuthToken). Returns null on failure. */
|
|
40
|
+
export declare function getGoogleProfile(token: string): Promise<{
|
|
41
|
+
name?: string;
|
|
42
|
+
email?: string;
|
|
43
|
+
} | null>;
|
|
35
44
|
//# sourceMappingURL=cloud.d.ts.map
|
|
@@ -161,44 +161,49 @@ async function gDriveReadFromFolder(folderId, fileName) {
|
|
|
161
161
|
return null;
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
|
-
/** Write a file by name to a folder (by ID) — creates or updates
|
|
164
|
+
/** Write a file by name to a folder (by ID) — creates or updates.
|
|
165
|
+
* Throws on failure with a descriptive error so callers can surface it to the UI. */
|
|
165
166
|
async function gDriveWriteToFolder(folderId, fileName, content) {
|
|
166
167
|
const token = await getGoogleDriveToken();
|
|
167
168
|
if (!token)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
169
|
+
throw new Error("Google Drive: no auth token (OAuth not granted or expired)");
|
|
170
|
+
// Check if file exists in folder
|
|
171
|
+
const query = `name='${fileName}' and '${folderId}' in parents and trashed=false`;
|
|
172
|
+
const findRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
|
|
173
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
174
|
+
});
|
|
175
|
+
if (!findRes.ok) {
|
|
176
|
+
const body = await findRes.text().catch(() => "");
|
|
177
|
+
throw new Error(`Google Drive: lookup '${fileName}' failed (${findRes.status} ${findRes.statusText}) ${body.slice(0, 200)}`);
|
|
178
|
+
}
|
|
179
|
+
const findData = await findRes.json();
|
|
180
|
+
const existingId = findData.files?.[0]?.id;
|
|
181
|
+
if (existingId) {
|
|
182
|
+
// Update existing file
|
|
183
|
+
const res = await fetch(`https://www.googleapis.com/upload/drive/v3/files/${existingId}?uploadType=media`, {
|
|
184
|
+
method: "PATCH",
|
|
185
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
186
|
+
body: content,
|
|
174
187
|
});
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// Update existing file
|
|
179
|
-
const res = await fetch(`https://www.googleapis.com/upload/drive/v3/files/${existingId}?uploadType=media`, {
|
|
180
|
-
method: "PATCH",
|
|
181
|
-
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
182
|
-
body: content,
|
|
183
|
-
});
|
|
184
|
-
return res.ok;
|
|
185
|
-
}
|
|
186
|
-
else {
|
|
187
|
-
// Create new file in folder
|
|
188
|
-
const boundary = "mailx_boundary_" + Date.now();
|
|
189
|
-
const metadata = JSON.stringify({ name: fileName, parents: [folderId] });
|
|
190
|
-
const body = `--${boundary}\r\nContent-Type: application/json\r\n\r\n${metadata}\r\n--${boundary}\r\nContent-Type: application/json\r\n\r\n${content}\r\n--${boundary}--`;
|
|
191
|
-
const res = await fetch("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart", {
|
|
192
|
-
method: "POST",
|
|
193
|
-
headers: { Authorization: `Bearer ${token}`, "Content-Type": `multipart/related; boundary=${boundary}` },
|
|
194
|
-
body,
|
|
195
|
-
});
|
|
196
|
-
return res.ok;
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
const body = await res.text().catch(() => "");
|
|
190
|
+
throw new Error(`Google Drive: update '${fileName}' failed (${res.status} ${res.statusText}) ${body.slice(0, 200)}`);
|
|
197
191
|
}
|
|
198
192
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
193
|
+
else {
|
|
194
|
+
// Create new file in folder
|
|
195
|
+
const boundary = "mailx_boundary_" + Date.now();
|
|
196
|
+
const metadata = JSON.stringify({ name: fileName, parents: [folderId] });
|
|
197
|
+
const body = `--${boundary}\r\nContent-Type: application/json\r\n\r\n${metadata}\r\n--${boundary}\r\nContent-Type: application/json\r\n\r\n${content}\r\n--${boundary}--`;
|
|
198
|
+
const res = await fetch("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart", {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": `multipart/related; boundary=${boundary}` },
|
|
201
|
+
body,
|
|
202
|
+
});
|
|
203
|
+
if (!res.ok) {
|
|
204
|
+
const errBody = await res.text().catch(() => "");
|
|
205
|
+
throw new Error(`Google Drive: create '${fileName}' failed (${res.status} ${res.statusText}) ${errBody.slice(0, 200)}`);
|
|
206
|
+
}
|
|
202
207
|
}
|
|
203
208
|
}
|
|
204
209
|
/**
|
|
@@ -226,13 +231,7 @@ export function getCloudProvider(provider, folderId) {
|
|
|
226
231
|
catch {
|
|
227
232
|
return null;
|
|
228
233
|
} },
|
|
229
|
-
write: async (p, c) => {
|
|
230
|
-
fs.writeFileSync(p, c);
|
|
231
|
-
return true;
|
|
232
|
-
}
|
|
233
|
-
catch {
|
|
234
|
-
return false;
|
|
235
|
-
} },
|
|
234
|
+
write: async (p, c) => { fs.writeFileSync(p, c); },
|
|
236
235
|
exists: async (p) => fs.existsSync(p),
|
|
237
236
|
};
|
|
238
237
|
default:
|
|
@@ -240,4 +239,32 @@ export function getCloudProvider(provider, folderId) {
|
|
|
240
239
|
return null;
|
|
241
240
|
}
|
|
242
241
|
}
|
|
242
|
+
/** Fetch the authenticated Google user's profile (name + email) via the People API.
|
|
243
|
+
* Caller must supply an OAuth access token whose scopes include `contacts.readonly`
|
|
244
|
+
* or `userinfo.profile`. The Drive token (drive.file) does NOT have the right scope
|
|
245
|
+
* on its own — use the Gmail/IMAP OAuth token instead (mailx-imap's
|
|
246
|
+
* ImapManager.getOAuthToken). Returns null on failure. */
|
|
247
|
+
export async function getGoogleProfile(token) {
|
|
248
|
+
if (!token)
|
|
249
|
+
return null;
|
|
250
|
+
try {
|
|
251
|
+
const res = await fetch("https://people.googleapis.com/v1/people/me?personFields=names,emailAddresses", {
|
|
252
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
253
|
+
});
|
|
254
|
+
if (!res.ok) {
|
|
255
|
+
console.error(` [cloud] People API: ${res.status} ${res.statusText}`);
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
const data = await res.json();
|
|
259
|
+
const name = data.names?.find((n) => n.displayName)?.displayName
|
|
260
|
+
|| data.names?.[0]?.displayName;
|
|
261
|
+
const email = data.emailAddresses?.find((e) => e.metadata?.primary)?.value
|
|
262
|
+
|| data.emailAddresses?.[0]?.value;
|
|
263
|
+
return { name, email };
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
console.error(` [cloud] getGoogleProfile: ${e.message}`);
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
243
270
|
//# sourceMappingURL=cloud.js.map
|
|
@@ -17,11 +17,20 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import type { MailxSettings, AccountConfig, AutocompleteSettings } from "@bobfrankston/mailx-types";
|
|
19
19
|
declare const LOCAL_DIR: string;
|
|
20
|
+
/** Subscribers notified whenever lastCloudError changes (push to UI immediately). */
|
|
21
|
+
type CloudErrorListener = (error: string | null, context?: {
|
|
22
|
+
op: "read" | "write";
|
|
23
|
+
filename: string;
|
|
24
|
+
}) => void;
|
|
25
|
+
export declare function onCloudError(cb: CloudErrorListener): () => void;
|
|
26
|
+
export declare function getLastCloudError(): string | null;
|
|
20
27
|
declare function getSharedDir(): string;
|
|
21
28
|
/** Read a file via cloud API (when filesystem mount not available) */
|
|
22
29
|
export declare function cloudRead(filename: string): Promise<string | null>;
|
|
23
|
-
/** Write a file via cloud API
|
|
24
|
-
|
|
30
|
+
/** Write a file via cloud API. Throws on failure with a descriptive error,
|
|
31
|
+
* and updates lastCloudError so UI banners pick it up via getStorageInfo()
|
|
32
|
+
* and the onCloudError listener. */
|
|
33
|
+
export declare function cloudWrite(filename: string, content: string): Promise<void>;
|
|
25
34
|
/** Whether cloud API fallback is active */
|
|
26
35
|
export declare function isCloudMode(): boolean;
|
|
27
36
|
/** Get storage provider info for display */
|
|
@@ -62,6 +62,25 @@ function resolveProvider(cfg) {
|
|
|
62
62
|
let pendingCloudConfig = null;
|
|
63
63
|
/** Last cloud API error (for UI display) */
|
|
64
64
|
let lastCloudError = null;
|
|
65
|
+
const cloudErrorListeners = [];
|
|
66
|
+
export function onCloudError(cb) {
|
|
67
|
+
cloudErrorListeners.push(cb);
|
|
68
|
+
return () => {
|
|
69
|
+
const i = cloudErrorListeners.indexOf(cb);
|
|
70
|
+
if (i >= 0)
|
|
71
|
+
cloudErrorListeners.splice(i, 1);
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function setCloudError(error, context) {
|
|
75
|
+
lastCloudError = error;
|
|
76
|
+
for (const cb of cloudErrorListeners) {
|
|
77
|
+
try {
|
|
78
|
+
cb(error, context);
|
|
79
|
+
}
|
|
80
|
+
catch { /* listener faults shouldn't break writes */ }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function getLastCloudError() { return lastCloudError; }
|
|
65
84
|
function resolveSharedEntry(entry) {
|
|
66
85
|
if (typeof entry === "string") {
|
|
67
86
|
const p = resolvePath(entry);
|
|
@@ -97,16 +116,20 @@ export async function cloudRead(filename) {
|
|
|
97
116
|
pendingCloudConfig.folderId = await gDriveFindOrCreateFolder() || undefined;
|
|
98
117
|
if (pendingCloudConfig.folderId)
|
|
99
118
|
saveFolderIdToConfig(pendingCloudConfig.folderId);
|
|
119
|
+
if (!pendingCloudConfig.folderId) {
|
|
120
|
+
setCloudError(`Cannot read ${filename}: Google Drive folder unavailable (OAuth not granted?)`, { op: "read", filename });
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
100
123
|
}
|
|
101
124
|
const provider = getCloudProvider(pendingCloudConfig.provider, pendingCloudConfig.folderId);
|
|
102
125
|
if (!provider) {
|
|
103
|
-
|
|
126
|
+
setCloudError(`No cloud provider for ${pendingCloudConfig.provider}`, { op: "read", filename });
|
|
104
127
|
return null;
|
|
105
128
|
}
|
|
106
129
|
console.log(` [cloud] Reading ${filename} via ${pendingCloudConfig.provider} API...`);
|
|
107
130
|
const content = await provider.read(filename);
|
|
108
131
|
if (content) {
|
|
109
|
-
|
|
132
|
+
setCloudError(null);
|
|
110
133
|
// Cache locally
|
|
111
134
|
try {
|
|
112
135
|
fs.mkdirSync(LOCAL_DIR, { recursive: true });
|
|
@@ -117,20 +140,40 @@ export async function cloudRead(filename) {
|
|
|
117
140
|
// Don't set error for missing files — they may not exist yet (e.g., clients.jsonc on first run)
|
|
118
141
|
return content;
|
|
119
142
|
}
|
|
120
|
-
/** Write a file via cloud API
|
|
143
|
+
/** Write a file via cloud API. Throws on failure with a descriptive error,
|
|
144
|
+
* and updates lastCloudError so UI banners pick it up via getStorageInfo()
|
|
145
|
+
* and the onCloudError listener. */
|
|
121
146
|
export async function cloudWrite(filename, content) {
|
|
122
|
-
if (!pendingCloudConfig)
|
|
123
|
-
|
|
147
|
+
if (!pendingCloudConfig) {
|
|
148
|
+
const err = `No cloud configured — cannot save ${filename} to cloud`;
|
|
149
|
+
setCloudError(err, { op: "write", filename });
|
|
150
|
+
throw new Error(err);
|
|
151
|
+
}
|
|
124
152
|
// Ensure we have a folder ID
|
|
125
153
|
if (!pendingCloudConfig.folderId) {
|
|
126
154
|
pendingCloudConfig.folderId = await gDriveFindOrCreateFolder() || undefined;
|
|
127
155
|
if (pendingCloudConfig.folderId)
|
|
128
156
|
saveFolderIdToConfig(pendingCloudConfig.folderId);
|
|
157
|
+
if (!pendingCloudConfig.folderId) {
|
|
158
|
+
const err = `Cannot save ${filename}: Google Drive folder unavailable (OAuth not granted or token expired)`;
|
|
159
|
+
setCloudError(err, { op: "write", filename });
|
|
160
|
+
throw new Error(err);
|
|
161
|
+
}
|
|
129
162
|
}
|
|
130
163
|
const provider = getCloudProvider(pendingCloudConfig.provider, pendingCloudConfig.folderId);
|
|
131
|
-
if (!provider)
|
|
132
|
-
|
|
133
|
-
|
|
164
|
+
if (!provider) {
|
|
165
|
+
const err = `No cloud provider for ${pendingCloudConfig.provider}`;
|
|
166
|
+
setCloudError(err, { op: "write", filename });
|
|
167
|
+
throw new Error(err);
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
await provider.write(filename, content);
|
|
171
|
+
setCloudError(null);
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
setCloudError(e.message, { op: "write", filename });
|
|
175
|
+
throw e;
|
|
176
|
+
}
|
|
134
177
|
}
|
|
135
178
|
/** Persist the discovered folder ID back to config.jsonc so we don't search again */
|
|
136
179
|
function saveFolderIdToConfig(folderId) {
|
|
@@ -231,7 +274,12 @@ function loadFile(filename, defaults) {
|
|
|
231
274
|
}
|
|
232
275
|
return { ...defaults, ...data };
|
|
233
276
|
}
|
|
234
|
-
/** Save a config file to the shared directory (and cloud API if active)
|
|
277
|
+
/** Save a config file to the shared directory (and cloud API if active).
|
|
278
|
+
* Always writes the local copy first so the data is never lost.
|
|
279
|
+
* If a cloud provider is configured, also writes there — failures set
|
|
280
|
+
* lastCloudError and notify onCloudError listeners so the UI can show
|
|
281
|
+
* a banner. The cloud write is fire-and-forget at this layer; callers
|
|
282
|
+
* who need the result should use the typed save* helpers below. */
|
|
235
283
|
function saveFile(filename, data) {
|
|
236
284
|
const sharedDir = getSharedDir();
|
|
237
285
|
atomicWrite(path.join(sharedDir, filename), data);
|
|
@@ -244,12 +292,13 @@ function saveFile(filename, data) {
|
|
|
244
292
|
}
|
|
245
293
|
// Write to cloud API if filesystem mount unavailable (creates app-owned file for drive.file scope)
|
|
246
294
|
if (pendingCloudConfig) {
|
|
247
|
-
cloudWrite(filename, JSON.stringify(data, null, 2))
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
295
|
+
cloudWrite(filename, JSON.stringify(data, null, 2))
|
|
296
|
+
.then(() => console.log(` [cloud] Saved ${filename} via ${pendingCloudConfig.provider} API`))
|
|
297
|
+
.catch(e => console.error(` [cloud] Failed to save ${filename}: ${e.message}`));
|
|
298
|
+
// Note: we don't await — saveFile is sync. cloudWrite() already calls
|
|
299
|
+
// setCloudError(), which fires onCloudError listeners synchronously
|
|
300
|
+
// when the promise rejects, so the UI gets the failure even though
|
|
301
|
+
// we don't propagate it back through the call chain here.
|
|
253
302
|
}
|
|
254
303
|
}
|
|
255
304
|
const PROVIDERS = {
|
|
@@ -345,6 +345,12 @@ export class MailxDB {
|
|
|
345
345
|
const term = `%${query.search}%`;
|
|
346
346
|
params.push(term, term, term);
|
|
347
347
|
}
|
|
348
|
+
if (query.flaggedOnly) {
|
|
349
|
+
// flags_json is a JSON array like ["\\Seen","\\Flagged"]. A plain
|
|
350
|
+
// LIKE on the serialized form is sufficient to find rows with the
|
|
351
|
+
// \Flagged flag without decoding every row.
|
|
352
|
+
where += " AND flags_json LIKE '%\\\\Flagged%'";
|
|
353
|
+
}
|
|
348
354
|
const total = this.db.prepare(`SELECT COUNT(*) as cnt FROM messages WHERE ${where}`).get(...params).cnt;
|
|
349
355
|
const rows = this.db.prepare(`SELECT * FROM messages WHERE ${where} ORDER BY ${sortCol} ${sortDir} LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
|
|
350
356
|
const items = rows.map(r => ({
|
|
@@ -218,13 +218,24 @@ export class WebMailxService {
|
|
|
218
218
|
raw = await this.syncManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
|
|
219
219
|
}
|
|
220
220
|
catch (fetchErr) {
|
|
221
|
+
// Mirror the desktop service: surface as structured bodyError
|
|
222
|
+
// so the viewer shows its dedicated error banner instead of
|
|
223
|
+
// rendering the message text verbatim in the body area.
|
|
224
|
+
const rawErr = fetchErr.message || "fetch failed";
|
|
225
|
+
const isTransient = /connection|Too many|UNAVAILABLE|rate|429|5\d\d|timeout|ENOTFOUND|ECONNRESET|ETIMEDOUT/i.test(rawErr);
|
|
221
226
|
return {
|
|
222
|
-
...envelope, bodyHtml: "", bodyText:
|
|
227
|
+
...envelope, bodyHtml: "", bodyText: "",
|
|
228
|
+
bodyError: rawErr, bodyErrorTransient: isTransient,
|
|
223
229
|
hasRemoteContent: false, remoteAllowed: false, attachments: [], deliveredTo: "", returnPath: "", listUnsubscribe: ""
|
|
224
230
|
};
|
|
225
231
|
}
|
|
226
232
|
if (!raw) {
|
|
227
|
-
|
|
233
|
+
return {
|
|
234
|
+
...envelope, bodyHtml: "", bodyText: "",
|
|
235
|
+
bodyError: "Message body not cached locally and the server fetch returned nothing.",
|
|
236
|
+
bodyErrorTransient: true,
|
|
237
|
+
hasRemoteContent: false, remoteAllowed: false, attachments: [], deliveredTo: "", returnPath: "", listUnsubscribe: ""
|
|
238
|
+
};
|
|
228
239
|
}
|
|
229
240
|
else {
|
|
230
241
|
const source = new TextDecoder().decode(raw);
|
|
@@ -106,6 +106,10 @@ export interface MessageQuery {
|
|
|
106
106
|
sort?: "date" | "from" | "subject";
|
|
107
107
|
sortDir?: "asc" | "desc";
|
|
108
108
|
search?: string;
|
|
109
|
+
/** Restrict to messages with the \Flagged flag set (whole-folder, not
|
|
110
|
+
* just the currently-rendered page — lets the "show flagged" filter
|
|
111
|
+
* find stars on messages that haven't been paged in yet). */
|
|
112
|
+
flaggedOnly?: boolean;
|
|
109
113
|
}
|
|
110
114
|
/** Compose/send a message */
|
|
111
115
|
export interface ComposeMessage {
|