@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.
@@ -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<boolean>;
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
- return false;
169
- try {
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}` },
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
- const findData = await findRes.json();
176
- const existingId = findData.files?.[0]?.id;
177
- if (existingId) {
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
- catch (e) {
200
- console.error(` [cloud] gdrive write ${fileName}: ${e.message}`);
201
- return false;
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) => { try {
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
- export declare function cloudWrite(filename: string, content: string): Promise<boolean>;
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
- lastCloudError = `No cloud provider for ${pendingCloudConfig.provider}`;
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
- lastCloudError = null;
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
- return false;
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
- return false;
133
- return provider.write(filename, content);
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)).then(ok => {
248
- if (ok)
249
- console.log(` [cloud] Saved ${filename} via ${pendingCloudConfig.provider} API`);
250
- else
251
- console.error(` [cloud] Failed to save ${filename} via ${pendingCloudConfig.provider} API`);
252
- }).catch(() => { });
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: `[Message body unavailable: ${fetchErr.message || "fetch failed"}]`,
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
- bodyText = "[Message body not available. Try again or re-sync the folder.]";
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 {
@@ -6,6 +6,7 @@
6
6
  "allowSyntheticDefaultImports": true,
7
7
  "esModuleInterop": true,
8
8
  "allowJs": true,
9
+ "resolveJsonModule": true,
9
10
  "strict": true,
10
11
  "forceConsistentCasingInFileNames": true,
11
12
  "skipLibCheck": true,