@bobfrankston/mailx-sync 0.1.7 → 0.1.9

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/gmail.d.ts CHANGED
@@ -51,6 +51,18 @@ export declare class GmailApiProvider implements MailProvider {
51
51
  * remove so the end state matches regardless of what was there before,
52
52
  * which makes the call idempotent and safe to retry. */
53
53
  setFlags(folder: string, uid: number, flags: string[]): Promise<void>;
54
+ /** Move a message to the trash label. Gmail treats trash as a label, not
55
+ * as a destination folder — `POST /messages/{id}/trash` is the native
56
+ * path (equivalent to setting TRASH and removing INBOX in one op).
57
+ * Used by mailx's delete/trash path. */
58
+ trashMessage(folder: string, uid: number): Promise<void>;
59
+ /** Move between "folders" == swap one label for another via modifyLabels.
60
+ * System labels (INBOX/SENT/TRASH/SPAM) are translated from the folder
61
+ * path; user labels use the folder path verbatim as the label id. */
62
+ moveMessage(fromFolder: string, uid: number, toFolder: string): Promise<void>;
63
+ /** Folder path → Gmail label id. System folders map to uppercase label
64
+ * constants; anything else is treated as a user label (identical name). */
65
+ private folderPathToLabelId;
54
66
  getUids(folder: string): Promise<number[]>;
55
67
  close(): Promise<void>;
56
68
  /** Map folder path to Gmail label query term */
package/gmail.js CHANGED
@@ -207,11 +207,15 @@ export class GmailApiProvider {
207
207
  * real messages from the local DB. Returning [] silently caused the
208
208
  * "INBOX empty in mailx" bug when a rate-limit hit mid-pagination. */
209
209
  async listMessageIds(query, maxResults = 500) {
210
+ // maxResults === 0 → unlimited (for date-bounded queries where the
211
+ // query itself caps the result set).
212
+ const unlimited = maxResults === 0;
210
213
  const ids = [];
211
214
  let pageToken = "";
212
215
  let truncated = false;
213
216
  while (true) {
214
- const params = new URLSearchParams({ q: query, maxResults: String(Math.min(maxResults - ids.length, 500)) });
217
+ const pageSize = unlimited ? 500 : Math.min(maxResults - ids.length, 500);
218
+ const params = new URLSearchParams({ q: query, maxResults: String(pageSize) });
215
219
  if (pageToken)
216
220
  params.set("pageToken", pageToken);
217
221
  const data = await this.fetch(`/messages?${params}`);
@@ -220,7 +224,7 @@ export class GmailApiProvider {
220
224
  }
221
225
  if (!data.nextPageToken)
222
226
  break;
223
- if (ids.length >= maxResults) {
227
+ if (!unlimited && ids.length >= maxResults) {
224
228
  // Hit the caller's cap but the server has more. Flag it so
225
229
  // reconcile-style callers can refuse to treat this as complete.
226
230
  truncated = true;
@@ -299,19 +303,27 @@ export class GmailApiProvider {
299
303
  async fetchSince(folder, sinceUid, options = {}) {
300
304
  // Gmail message IDs are hash-derived, NOT monotonic — filtering by
301
305
  // `uid > sinceUid` silently drops new messages whose hash happens to
302
- // fall below the high-water mark. Fetch the most recent page and let
303
- // upsertMessage dedupe by (account, folder, uid). The sinceUid arg is
304
- // kept for interface compatibility but no longer used for filtering.
306
+ // fall below the high-water mark. Fetch by date (when caller supplied
307
+ // a `since` window) or fall back to the recent-200 page.
305
308
  void sinceUid;
306
- const query = `in:${this.folderToLabel(folder)}`;
307
- const ids = await this.listMessageIds(query, 200);
309
+ let query = `in:${this.folderToLabel(folder)}`;
310
+ if (options.since) {
311
+ // after: is inclusive at day granularity; filter precisely on the
312
+ // client side since Gmail's search is whole-day.
313
+ query += ` after:${this.formatDate(options.since)}`;
314
+ }
315
+ // When the caller bounded by date, let pagination run — the query
316
+ // is self-limiting. Otherwise cap at the recent 200.
317
+ const cap = options.since ? 0 : 200;
318
+ const ids = await this.listMessageIds(query, cap);
308
319
  return this.batchFetch(ids, options);
309
320
  }
310
321
  async fetchByDate(folder, since, before, options = {}, onChunk) {
311
322
  const afterDate = this.formatDate(since);
312
323
  const beforeDate = this.formatDate(before);
313
324
  const query = `in:${this.folderToLabel(folder)} after:${afterDate} before:${beforeDate}`;
314
- const ids = await this.listMessageIds(query);
325
+ // Date-bounded let pagination drain the full range instead of stopping at 500.
326
+ const ids = await this.listMessageIds(query, 0);
315
327
  return this.batchFetch(ids, options, onChunk);
316
328
  }
317
329
  async fetchByUids(folder, uids, options = {}) {
@@ -427,6 +439,61 @@ export class GmailApiProvider {
427
439
  body: JSON.stringify({ addLabelIds, removeLabelIds }),
428
440
  });
429
441
  }
442
+ /** Move a message to the trash label. Gmail treats trash as a label, not
443
+ * as a destination folder — `POST /messages/{id}/trash` is the native
444
+ * path (equivalent to setting TRASH and removing INBOX in one op).
445
+ * Used by mailx's delete/trash path. */
446
+ async trashMessage(folder, uid) {
447
+ const query = `in:${this.folderToLabel(folder)}`;
448
+ const ids = await this.listMessageIds(query, 1000);
449
+ const id = ids.find(id => idToUid(id) === uid);
450
+ if (!id)
451
+ throw new Error(`Gmail trashMessage: UID ${uid} not found in ${folder}`);
452
+ await this.fetch(`/messages/${id}/trash`, { method: "POST" });
453
+ }
454
+ /** Move between "folders" == swap one label for another via modifyLabels.
455
+ * System labels (INBOX/SENT/TRASH/SPAM) are translated from the folder
456
+ * path; user labels use the folder path verbatim as the label id. */
457
+ async moveMessage(fromFolder, uid, toFolder) {
458
+ const query = `in:${this.folderToLabel(fromFolder)}`;
459
+ const ids = await this.listMessageIds(query, 1000);
460
+ const id = ids.find(id => idToUid(id) === uid);
461
+ if (!id)
462
+ throw new Error(`Gmail moveMessage: UID ${uid} not found in ${fromFolder}`);
463
+ // Map the folder path to the label id. System labels are uppercased
464
+ // aliases; user labels are passed through as-is (Gmail's label ids
465
+ // for user-created labels match the visible label name).
466
+ const toLabel = this.folderPathToLabelId(toFolder);
467
+ const fromLabel = this.folderPathToLabelId(fromFolder);
468
+ const addLabelIds = [];
469
+ const removeLabelIds = [];
470
+ if (toLabel)
471
+ addLabelIds.push(toLabel);
472
+ if (fromLabel && fromLabel !== toLabel)
473
+ removeLabelIds.push(fromLabel);
474
+ await this.fetch(`/messages/${id}/modify`, {
475
+ method: "POST",
476
+ body: JSON.stringify({ addLabelIds, removeLabelIds }),
477
+ });
478
+ }
479
+ /** Folder path → Gmail label id. System folders map to uppercase label
480
+ * constants; anything else is treated as a user label (identical name). */
481
+ folderPathToLabelId(path) {
482
+ const lower = path.toLowerCase();
483
+ if (lower === "inbox")
484
+ return "INBOX";
485
+ if (lower === "sent" || lower === "[gmail]/sent mail")
486
+ return "SENT";
487
+ if (lower === "drafts" || lower === "[gmail]/drafts")
488
+ return "DRAFT";
489
+ if (lower === "trash" || lower === "[gmail]/trash")
490
+ return "TRASH";
491
+ if (lower === "spam" || lower === "junk email" || lower === "[gmail]/spam")
492
+ return "SPAM";
493
+ if (lower === "archive" || lower === "[gmail]/all mail")
494
+ return ""; // no-op — archive is absence-of-INBOX
495
+ return path; // user label — name-as-id
496
+ }
430
497
  async getUids(folder) {
431
498
  const query = `in:${this.folderToLabel(folder)}`;
432
499
  const ids = await this.listMessageIds(query, 10000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-sync",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Platform-agnostic mail provider implementations + sync orchestration. Single source of truth for Gmail/IMAP/Outlook protocol code, consumed by both desktop (Node) and Android (WebView) — eliminates the parallel mailx-imap/mailx-store-web Gmail providers that drifted in practice.",
5
5
  "main": "index.js",
6
6
  "types": "index.ts",
@@ -19,7 +19,7 @@
19
19
  "author": "Bob Frankston",
20
20
  "license": "ISC",
21
21
  "dependencies": {
22
- "@bobfrankston/iflow-direct": "^0.1.23",
22
+ "@bobfrankston/iflow-direct": "^0.1.26",
23
23
  "@bobfrankston/tcp-transport": "^0.1.4"
24
24
  },
25
25
  "exports": {
@@ -44,7 +44,7 @@
44
44
  },
45
45
  ".transformedSnapshot": {
46
46
  "dependencies": {
47
- "@bobfrankston/iflow-direct": "^0.1.23",
47
+ "@bobfrankston/iflow-direct": "^0.1.26",
48
48
  "@bobfrankston/tcp-transport": "^0.1.4"
49
49
  }
50
50
  }
package/types.d.ts CHANGED
@@ -43,6 +43,10 @@ export interface ProviderMessage {
43
43
  export interface FetchOptions {
44
44
  source?: boolean;
45
45
  providerId?: string;
46
+ /** Lower bound for "since" queries — bounds the result set by date so
47
+ * the default page-count cap doesn't silently truncate a large folder
48
+ * to the last ~200 messages. */
49
+ since?: Date;
46
50
  }
47
51
  /**
48
52
  * A mail provider that can list folders, fetch messages, and perform actions.