@bobfrankston/mailx 1.0.265 → 1.0.283

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.
@@ -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 = {
@@ -66,6 +66,7 @@ export declare class MailxDB {
66
66
  hasAttachments: boolean;
67
67
  preview: string;
68
68
  bodyPath: string;
69
+ providerId?: string;
69
70
  }): number;
70
71
  getMessages(query: MessageQuery): PagedResult<MessageEnvelope>;
71
72
  /** Unified inbox: all inbox folders across accounts, sorted by date, paginated in SQL */
@@ -146,6 +146,11 @@ export class MailxDB {
146
146
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(account_id, thread_id)");
147
147
  }
148
148
  catch { /* already exists */ }
149
+ // provider_id: native server-side id for API-backed providers (Gmail
150
+ // hex id, Outlook Graph id, etc.). Lets fetchOne look up the message
151
+ // directly instead of paginating listMessageIds for every body fetch
152
+ // — a UID-only path costs 2-3 rate-limited API calls per message.
153
+ this.addColumnIfMissing("messages", "provider_id", "TEXT");
149
154
  }
150
155
  // ── Sent-log (dedup) ──
151
156
  /** Has this Message-ID already been sent? Used to prevent the outbox from
@@ -301,8 +306,13 @@ export class MailxDB {
301
306
  }
302
307
  // ── Messages ──
303
308
  upsertMessage(msg) {
304
- const existing = this.db.prepare("SELECT id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?").get(msg.accountId, msg.folderId, msg.uid);
309
+ const existing = this.db.prepare("SELECT id, provider_id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?").get(msg.accountId, msg.folderId, msg.uid);
305
310
  if (existing) {
311
+ // Backfill provider_id on existing rows that predate this column —
312
+ // critical for body fetch to bypass listMessageIds pagination.
313
+ if (msg.providerId && !existing.provider_id) {
314
+ this.db.prepare("UPDATE messages SET provider_id = ? WHERE id = ?").run(msg.providerId, existing.id);
315
+ }
306
316
  this.db.prepare(`
307
317
  UPDATE messages SET flags_json = ?, preview = ?, body_path = ?, cached_at = ?
308
318
  WHERE id = ?
@@ -320,9 +330,9 @@ export class MailxDB {
320
330
  INSERT INTO messages (
321
331
  account_id, folder_id, uid, message_id, in_reply_to, refs, thread_id,
322
332
  date, subject, from_address, from_name, to_json, cc_json,
323
- flags_json, size, has_attachments, preview, body_path, cached_at
324
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
325
- `).run(msg.accountId, msg.folderId, msg.uid, msg.messageId, msg.inReplyTo, JSON.stringify(msg.references), threadId, msg.date, msg.subject, msg.from.address, msg.from.name, JSON.stringify(msg.to), JSON.stringify(msg.cc), JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0, msg.preview, msg.bodyPath, Date.now());
333
+ flags_json, size, has_attachments, preview, body_path, cached_at, provider_id
334
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
335
+ `).run(msg.accountId, msg.folderId, msg.uid, msg.messageId, msg.inReplyTo, JSON.stringify(msg.references), threadId, msg.date, msg.subject, msg.from.address, msg.from.name, JSON.stringify(msg.to), JSON.stringify(msg.cc), JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0, msg.preview, msg.bodyPath, Date.now(), msg.providerId || null);
326
336
  const rowId = Number(result.lastInsertRowid);
327
337
  // Index for full-text search
328
338
  try {
@@ -345,6 +355,12 @@ export class MailxDB {
345
355
  const term = `%${query.search}%`;
346
356
  params.push(term, term, term);
347
357
  }
358
+ if (query.flaggedOnly) {
359
+ // flags_json is a JSON array like ["\\Seen","\\Flagged"]. A plain
360
+ // LIKE on the serialized form is sufficient to find rows with the
361
+ // \Flagged flag without decoding every row.
362
+ where += " AND flags_json LIKE '%\\\\Flagged%'";
363
+ }
348
364
  const total = this.db.prepare(`SELECT COUNT(*) as cnt FROM messages WHERE ${where}`).get(...params).cnt;
349
365
  const rows = this.db.prepare(`SELECT * FROM messages WHERE ${where} ORDER BY ${sortCol} ${sortDir} LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
350
366
  const items = rows.map(r => ({
@@ -427,7 +443,8 @@ export class MailxDB {
427
443
  flags: JSON.parse(r.flags_json),
428
444
  size: r.size,
429
445
  hasAttachments: !!r.has_attachments,
430
- preview: r.preview
446
+ preview: r.preview,
447
+ providerId: r.provider_id || undefined,
431
448
  };
432
449
  }
433
450
  getMessageBodyPath(accountId, uid) {
@@ -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);
@@ -34,6 +34,7 @@ export interface AccountConfig {
34
34
  relayDomains?: string[]; /** Domains to skip in Delivered-To chain (e.g., ["m.connectivity.xyz"]) */
35
35
  deliveredToPrefix?: string[]; /** Prefixes to strip from Delivered-To to get clean alias (e.g., ["bobf-ma-", "bobf-"]) — order matters, longest first */
36
36
  identityDomains?: string[]; /** Domains where Delivered-To address should become the reply From (e.g., ["bob.ma", "bobf.frankston.com"]) */
37
+ spam?: string; /** IMAP folder path for "Mark as spam" button (e.g., "_spam"). Button hidden when not set. */
37
38
  }
38
39
  /** Standard IMAP special-use folder types */
39
40
  export type SpecialUse = "inbox" | "sent" | "drafts" | "trash" | "junk" | "archive" | "all";
@@ -75,6 +76,7 @@ export interface MessageEnvelope {
75
76
  hasAttachments: boolean;
76
77
  preview: string; /** First ~200 chars of body text */
77
78
  bodyPath?: string; /** Local body location: "idb:..." or "gmail:<id>" */
79
+ providerId?: string; /** Native server id (Gmail hex id, Outlook Graph id) — bypasses UID→id pagination on body fetch */
78
80
  }
79
81
  /** Full message with body content */
80
82
  export interface Message extends MessageEnvelope {
@@ -106,6 +108,10 @@ export interface MessageQuery {
106
108
  sort?: "date" | "from" | "subject";
107
109
  sortDir?: "asc" | "desc";
108
110
  search?: string;
111
+ /** Restrict to messages with the \Flagged flag set (whole-folder, not
112
+ * just the currently-rendered page — lets the "show flagged" filter
113
+ * find stars on messages that haven't been paged in yet). */
114
+ flaggedOnly?: boolean;
109
115
  }
110
116
  /** Compose/send a message */
111
117
  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,