@bobfrankston/mailx 1.0.220 → 1.0.222

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.
@@ -157,6 +157,7 @@ function renderNode(node, container, depth) {
157
157
  const badge = document.createElement("span");
158
158
  badge.className = "ft-badge";
159
159
  badge.textContent = String(node.unreadCount);
160
+ badge.title = `${node.unreadCount} unread`;
160
161
  folderEl.appendChild(badge);
161
162
  }
162
163
  // Total count (shown when View > Folder counts is checked)
@@ -164,6 +165,7 @@ function renderNode(node, container, depth) {
164
165
  const total = document.createElement("span");
165
166
  total.className = "ft-total-count";
166
167
  total.textContent = String(node.totalCount);
168
+ total.title = `${node.totalCount} total messages`;
167
169
  folderEl.appendChild(total);
168
170
  }
169
171
  folderEl.addEventListener("click", () => {
@@ -587,6 +589,7 @@ async function loadFolderTree(container) {
587
589
  if (accounts.length > 1) {
588
590
  const unifiedEl = document.createElement("div");
589
591
  unifiedEl.className = "ft-folder ft-unified";
592
+ unifiedEl.title = "Merged inbox view of all accounts — click to see messages from every account's INBOX sorted by date";
590
593
  unifiedEl.innerHTML = `<span class="ft-toggle"> </span><span class="ft-folder-name">All Inboxes</span>`;
591
594
  unifiedEl.addEventListener("click", () => {
592
595
  if (selectedElement)
@@ -293,3 +293,93 @@ body {
293
293
  max-width: 60ch;
294
294
  }
295
295
  .ql-editor, .tt-content .tiptap { position: relative; }
296
+
297
+ /* Link editor modal (Ctrl+K / toolbar link button) */
298
+ .mailx-modal-backdrop {
299
+ position: fixed;
300
+ inset: 0;
301
+ background: rgba(0, 0, 0, 0.35);
302
+ display: flex;
303
+ align-items: center;
304
+ justify-content: center;
305
+ z-index: 2000;
306
+ }
307
+ .mailx-modal {
308
+ background: var(--color-bg);
309
+ color: var(--color-text);
310
+ padding: var(--gap-lg);
311
+ border-radius: var(--radius-md);
312
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
313
+ min-width: 420px;
314
+ max-width: 90vw;
315
+ display: flex;
316
+ flex-direction: column;
317
+ gap: var(--gap-md);
318
+ font-family: var(--font-ui);
319
+ }
320
+ .mailx-modal-title {
321
+ font-size: var(--font-size-lg);
322
+ font-weight: 600;
323
+ }
324
+ .mailx-modal-label {
325
+ display: flex;
326
+ flex-direction: column;
327
+ gap: 4px;
328
+ font-size: var(--font-size-sm);
329
+ color: var(--color-text-muted);
330
+ }
331
+ .mailx-modal-input {
332
+ padding: 6px 8px;
333
+ border: 1px solid var(--color-border);
334
+ border-radius: var(--radius-sm);
335
+ background: var(--color-bg-surface);
336
+ color: var(--color-text);
337
+ font-family: var(--font-ui);
338
+ font-size: var(--font-size-base);
339
+ }
340
+ .mailx-modal-input:focus {
341
+ outline: 2px solid var(--color-accent);
342
+ outline-offset: -1px;
343
+ }
344
+ .mailx-modal-buttons {
345
+ display: flex;
346
+ gap: var(--gap-sm);
347
+ align-items: center;
348
+ margin-top: var(--gap-xs);
349
+ }
350
+ .mailx-modal-spacer { flex: 1; }
351
+ .mailx-modal-btn {
352
+ padding: 6px 14px;
353
+ border: 1px solid var(--color-border);
354
+ border-radius: var(--radius-sm);
355
+ background: var(--color-bg-surface);
356
+ color: var(--color-text);
357
+ cursor: pointer;
358
+ font-size: var(--font-size-sm);
359
+ }
360
+ .mailx-modal-btn:hover { background: var(--color-bg-hover); }
361
+ .mailx-modal-btn-primary {
362
+ background: var(--color-accent);
363
+ color: #fff;
364
+ border-color: transparent;
365
+ font-weight: 500;
366
+ }
367
+ .mailx-modal-btn-primary:hover { filter: brightness(1.1); }
368
+
369
+ /* Link hover preview: small floating URL below the anchor */
370
+ .mailx-link-hover {
371
+ position: fixed;
372
+ padding: 4px 8px;
373
+ background: rgba(20, 20, 28, 0.92);
374
+ color: #e6e6f0;
375
+ font-size: 0.8rem;
376
+ font-family: var(--font-ui);
377
+ border-radius: var(--radius-sm);
378
+ max-width: 60ch;
379
+ overflow: hidden;
380
+ text-overflow: ellipsis;
381
+ white-space: nowrap;
382
+ z-index: 2100;
383
+ pointer-events: none;
384
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
385
+ }
@@ -2,24 +2,147 @@
2
2
  * Editor abstraction — wraps Quill or tiptap behind a common interface.
3
3
  * The compose window loads this module and calls createEditor() based on the user's setting.
4
4
  */
5
+ /** URL-ish test: accepts http(s)://, mailto:, tel:, and bare domains with a dot. */
6
+ function looksLikeUrl(s) {
7
+ const t = s.trim();
8
+ if (!t)
9
+ return false;
10
+ if (/^(https?|mailto|tel):/i.test(t))
11
+ return true;
12
+ // bare domain (e.g. "example.com/path") — require a dot and no internal whitespace
13
+ return /^[\w-]+(\.[\w-]+)+(\/\S*)?$/.test(t);
14
+ }
15
+ function normalizeUrl(s) {
16
+ const t = s.trim();
17
+ if (!t)
18
+ return t;
19
+ if (/^(https?|mailto|tel):/i.test(t))
20
+ return t;
21
+ if (/^[\w.+-]+@[\w-]+(\.[\w-]+)+$/.test(t))
22
+ return `mailto:${t}`;
23
+ return `https://${t}`;
24
+ }
25
+ /** Floating modal that edits both link text and URL. Returns null on Cancel,
26
+ * { text, url } on OK, or { text: "", url: "" } on "Remove link". */
27
+ function openLinkDialog(initialText, initialUrl) {
28
+ return new Promise(resolve => {
29
+ const backdrop = document.createElement("div");
30
+ backdrop.className = "mailx-modal-backdrop";
31
+ const panel = document.createElement("div");
32
+ panel.className = "mailx-modal";
33
+ panel.innerHTML = `
34
+ <div class="mailx-modal-title">Edit link</div>
35
+ <label class="mailx-modal-label">Text
36
+ <input type="text" class="mailx-modal-input" id="mailx-link-text">
37
+ </label>
38
+ <label class="mailx-modal-label">URL
39
+ <input type="text" class="mailx-modal-input" id="mailx-link-url" spellcheck="false" autocomplete="off">
40
+ </label>
41
+ <div class="mailx-modal-buttons">
42
+ <button type="button" class="mailx-modal-btn" data-action="remove">Remove link</button>
43
+ <span class="mailx-modal-spacer"></span>
44
+ <button type="button" class="mailx-modal-btn" data-action="cancel">Cancel</button>
45
+ <button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="ok">OK</button>
46
+ </div>`;
47
+ backdrop.appendChild(panel);
48
+ document.body.appendChild(backdrop);
49
+ const textInput = panel.querySelector("#mailx-link-text");
50
+ const urlInput = panel.querySelector("#mailx-link-url");
51
+ textInput.value = initialText;
52
+ urlInput.value = initialUrl;
53
+ const close = (result) => {
54
+ backdrop.remove();
55
+ document.removeEventListener("keydown", onKey, true);
56
+ resolve(result);
57
+ };
58
+ const commit = () => close({ text: textInput.value, url: normalizeUrl(urlInput.value) });
59
+ const onKey = (e) => {
60
+ if (e.key === "Escape") {
61
+ e.stopPropagation();
62
+ e.preventDefault();
63
+ close(null);
64
+ }
65
+ else if (e.key === "Enter") {
66
+ e.stopPropagation();
67
+ e.preventDefault();
68
+ commit();
69
+ }
70
+ };
71
+ document.addEventListener("keydown", onKey, true);
72
+ panel.querySelectorAll(".mailx-modal-btn").forEach(btn => {
73
+ btn.addEventListener("click", () => {
74
+ const action = btn.dataset.action;
75
+ if (action === "cancel")
76
+ close(null);
77
+ else if (action === "remove")
78
+ close({ text: textInput.value, url: "", remove: true });
79
+ else
80
+ commit();
81
+ });
82
+ });
83
+ backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
84
+ close(null); });
85
+ // Focus URL if we have text, else text first
86
+ (initialText ? urlInput : textInput).focus();
87
+ (initialText ? urlInput : textInput).select();
88
+ });
89
+ }
5
90
  function createQuillEditor(container) {
91
+ /** Open the link dialog for the current selection or cursor. If range has
92
+ * a selection, prefill text from the selected text; if cursor is inside
93
+ * a link, prefill both from the link's range. */
94
+ const openLinkForRange = async (quill, range) => {
95
+ if (!range)
96
+ return;
97
+ // Expand a bare cursor inside a link to the full link range
98
+ let linkRange = range;
99
+ const format = quill.getFormat(range);
100
+ if (range.length === 0 && format.link) {
101
+ // Walk left and right to find the link extent
102
+ const [leaf, offset] = quill.getLeaf(range.index);
103
+ if (leaf) {
104
+ // Build a range that spans the whole link
105
+ const text = quill.getText();
106
+ let start = range.index, end = range.index;
107
+ while (start > 0 && quill.getFormat(start - 1, 1).link === format.link)
108
+ start--;
109
+ while (end < text.length - 1 && quill.getFormat(end, 1).link === format.link)
110
+ end++;
111
+ linkRange = { index: start, length: end - start };
112
+ }
113
+ }
114
+ const currentText = linkRange.length ? quill.getText(linkRange.index, linkRange.length).replace(/\n$/, "") : "";
115
+ const currentUrl = format.link || "";
116
+ const result = await openLinkDialog(currentText, currentUrl);
117
+ if (!result)
118
+ return;
119
+ if (result.remove) {
120
+ if (linkRange.length)
121
+ quill.formatText(linkRange.index, linkRange.length, "link", false);
122
+ return;
123
+ }
124
+ if (!result.url)
125
+ return;
126
+ const newText = result.text || result.url;
127
+ if (linkRange.length) {
128
+ // Replace the existing text+link with new text+link
129
+ quill.deleteText(linkRange.index, linkRange.length);
130
+ quill.insertText(linkRange.index, newText, { link: result.url });
131
+ quill.setSelection(linkRange.index + newText.length, 0);
132
+ }
133
+ else {
134
+ quill.insertText(linkRange.index, newText, { link: result.url });
135
+ quill.setSelection(linkRange.index + newText.length, 0);
136
+ }
137
+ };
6
138
  // Extra keybindings for formatting that Quill doesn't wire up by default.
7
- // Ctrl+K (insert link) is the one most users expect; we also add shortcuts
139
+ // Ctrl+K (insert/edit link) is the one most users expect; we also add shortcuts
8
140
  // for strikethrough, lists, indent, color, and clear-formatting.
9
141
  const extraBindings = {
10
142
  insertLink: {
11
143
  key: "K", shortKey: true,
12
144
  handler: function (range) {
13
- if (!range)
14
- return true;
15
- const current = this.quill.getFormat(range).link || "";
16
- const url = prompt("URL (leave blank to remove link):", current);
17
- if (url === null)
18
- return;
19
- if (url === "")
20
- this.quill.format("link", false);
21
- else
22
- this.quill.format("link", url);
145
+ openLinkForRange(this.quill, range);
23
146
  },
24
147
  },
25
148
  removeLink: {
@@ -95,6 +218,75 @@ function createQuillEditor(container) {
95
218
  });
96
219
  // Make toolbar buttons non-tabbable so Tab goes straight to editor body
97
220
  document.querySelectorAll(".ql-toolbar button, .ql-toolbar select, .ql-toolbar .ql-picker-label").forEach(el => el.setAttribute("tabindex", "-1"));
221
+ // Native spell-check: WebView2 / Chromium underlines misspellings and the
222
+ // right-click menu offers "Add to dictionary". Quill clears spellcheck on
223
+ // its root by default, so we turn it back on explicitly.
224
+ q.root.setAttribute("spellcheck", "true");
225
+ q.root.setAttribute("autocorrect", "on");
226
+ q.root.setAttribute("autocapitalize", "on");
227
+ // Toolbar link button: open our modal instead of Quill's built-in URL prompt.
228
+ const toolbar = q.getModule("toolbar");
229
+ toolbar?.addHandler("link", function () {
230
+ openLinkForRange(q, q.getSelection() || { index: q.getLength() - 1, length: 0 });
231
+ });
232
+ // Paste handling:
233
+ // - If clipboard has text/html, let Quill convert it (preserves anchors).
234
+ // - If only text/plain and it's a URL, insert as a link (honoring any text
235
+ // currently selected — the URL becomes the href of that text).
236
+ // - Shift+Ctrl+V (handled via contextmenu "Paste as text") or any other
237
+ // plain text is inserted verbatim.
238
+ q.root.addEventListener("paste", (e) => {
239
+ const cb = e.clipboardData;
240
+ if (!cb)
241
+ return;
242
+ const html = cb.getData("text/html");
243
+ const plain = cb.getData("text/plain");
244
+ if (html)
245
+ return; // Quill handles HTML clipboard natively
246
+ if (plain && looksLikeUrl(plain)) {
247
+ e.preventDefault();
248
+ const range = q.getSelection(true);
249
+ if (!range)
250
+ return;
251
+ const url = normalizeUrl(plain);
252
+ if (range.length > 0) {
253
+ // Preserve existing selection text, just format it as a link
254
+ q.formatText(range.index, range.length, "link", url);
255
+ q.setSelection(range.index + range.length, 0);
256
+ }
257
+ else {
258
+ q.insertText(range.index, plain.trim(), { link: url });
259
+ q.setSelection(range.index + plain.trim().length, 0);
260
+ }
261
+ }
262
+ });
263
+ // Hover preview: show the target URL in a floating tooltip when the
264
+ // pointer is over a link. Built on top of native mouseover/mouseout
265
+ // rather than Quill's ql-tooltip (which is keyboard-triggered).
266
+ let hoverTip = null;
267
+ q.root.addEventListener("mouseover", (e) => {
268
+ const a = e.target.closest("a[href]");
269
+ if (!a)
270
+ return;
271
+ if (hoverTip)
272
+ hoverTip.remove();
273
+ hoverTip = document.createElement("div");
274
+ hoverTip.className = "mailx-link-hover";
275
+ hoverTip.textContent = a.getAttribute("href") || "";
276
+ document.body.appendChild(hoverTip);
277
+ const rect = a.getBoundingClientRect();
278
+ hoverTip.style.left = `${Math.max(8, rect.left)}px`;
279
+ hoverTip.style.top = `${rect.bottom + 4}px`;
280
+ });
281
+ q.root.addEventListener("mouseout", (e) => {
282
+ const to = e.relatedTarget;
283
+ if (to && to.closest("a[href]"))
284
+ return;
285
+ if (hoverTip) {
286
+ hoverTip.remove();
287
+ hoverTip = null;
288
+ }
289
+ });
98
290
  return {
99
291
  setHtml(html) {
100
292
  q.clipboard.dangerouslyPasteHTML(html);
@@ -166,11 +166,9 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
166
166
  align-items: center;
167
167
  gap: var(--gap-xs);
168
168
  padding: var(--gap-xs) var(--gap-sm);
169
- font-weight: 700;
169
+ font-weight: 600;
170
170
  font-size: var(--font-size-base);
171
171
  color: var(--color-brand-dark);
172
- text-transform: uppercase;
173
- letter-spacing: 0.05em;
174
172
  cursor: pointer;
175
173
  user-select: none;
176
174
  }
@@ -197,12 +195,12 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
197
195
  padding: var(--gap-xs) var(--gap-sm) var(--gap-xs) var(--gap-lg);
198
196
  cursor: pointer;
199
197
  font-size: var(--font-size-base);
200
- font-weight: 600;
198
+ font-weight: 500;
201
199
  color: var(--color-brand-dark);
202
200
  border-radius: 0;
203
201
 
204
202
  &:hover { background: var(--color-bg-hover); }
205
- &.selected { background: var(--color-bg-selected); color: var(--color-accent); }
203
+ &.selected { background: var(--color-bg-selected); color: var(--color-accent); font-weight: 600; }
206
204
  }
207
205
 
208
206
  .ft-folder-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -353,11 +351,13 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
353
351
  border-bottom: 1px solid color-mix(in oklch, var(--color-border) 50%, transparent);
354
352
  cursor: pointer;
355
353
  font-size: var(--font-size-base);
354
+ font-weight: 400;
356
355
  color: var(--color-text);
357
356
  align-items: baseline;
357
+ line-height: 1.35;
358
358
 
359
359
  &:hover { background: var(--color-bg-hover); }
360
- &.selected { background: var(--color-brand); color: var(--color-brand-dark); font-weight: 600; }
360
+ &.selected { background: var(--color-brand); color: var(--color-brand-dark); font-weight: 500; }
361
361
  &.unread {
362
362
  font-weight: 600;
363
363
  color: var(--color-unread);
@@ -411,7 +411,7 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
411
411
  }
412
412
  }
413
413
  .no-snippets .ml-preview { display: none; }
414
- .ml-date { white-space: nowrap; text-align: right; color: var(--color-text-muted); font-family: var(--font-mono); font-size: var(--font-size-sm); }
414
+ .ml-date { white-space: nowrap; text-align: right; color: var(--color-text-muted); font-size: var(--font-size-sm); font-variant-numeric: tabular-nums; }
415
415
  /* Not-downloaded indicator: small dot before the date */
416
416
  .ml-row.not-downloaded .ml-date::before {
417
417
  content: "○ ";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.220",
3
+ "version": "1.0.222",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -24,7 +24,7 @@
24
24
  "@bobfrankston/iflow-node": "^0.1.2",
25
25
  "@bobfrankston/miscinfo": "^1.0.8",
26
26
  "@bobfrankston/oauthsupport": "^1.0.22",
27
- "@bobfrankston/msger": "^0.1.282",
27
+ "@bobfrankston/msger": "^0.1.284",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -78,7 +78,7 @@
78
78
  "@bobfrankston/iflow-node": "^0.1.2",
79
79
  "@bobfrankston/miscinfo": "^1.0.8",
80
80
  "@bobfrankston/oauthsupport": "^1.0.22",
81
- "@bobfrankston/msger": "^0.1.282",
81
+ "@bobfrankston/msger": "^0.1.284",
82
82
  "@capacitor/android": "^8.3.0",
83
83
  "@capacitor/cli": "^8.3.0",
84
84
  "@capacitor/core": "^8.3.0",
@@ -946,20 +946,40 @@ export class ImapManager extends EventEmitter {
946
946
  console.log(` [api] ${accountId}/${folder.path}: ${messages.length} new messages`);
947
947
  this.storeApiMessages(accountId, folder.id, messages, highestUid);
948
948
  }
949
- // Reconcile deletions
949
+ // Reconcile deletions — messages present locally but not on the server.
950
+ // SAFETY: this used to silently wipe entire folders when getUids()
951
+ // returned a partial list (e.g. paginated fetch hit a rate limit and
952
+ // bailed). Multiple guards now:
953
+ // 1. getUids() flags partial results via _truncated — refuse to delete
954
+ // 2. If server list is empty but local isn't, assume a transient error
955
+ // 3. If reconcile would delete more than RECONCILE_DELETE_THRESHOLD of
956
+ // local messages, log and skip — safer to keep phantoms than to lose
957
+ // real messages. User can fix with `mailx -rebuild` if needed.
950
958
  try {
951
- const serverUids = new Set(await api.getUids(folder.path));
959
+ const serverUidsArr = await api.getUids(folder.path);
960
+ const serverUids = new Set(serverUidsArr);
952
961
  const localUids = this.db.getUidsForFolder(accountId, folder.id);
953
- let deleted = 0;
954
- for (const uid of localUids) {
955
- if (!serverUids.has(uid)) {
956
- this.db.deleteMessage(accountId, uid);
957
- this.bodyStore.deleteMessage(accountId, folder.id, uid).catch(() => { });
958
- deleted++;
962
+ if (serverUidsArr._truncated) {
963
+ console.log(` [api] ${accountId}/${folder.path}: reconcile skipped — server list truncated (${serverUidsArr.length} ids)`);
964
+ }
965
+ else if (serverUidsArr.length === 0 && localUids.length > 0) {
966
+ console.log(` [api] ${accountId}/${folder.path}: reconcile skipped — server list empty but local has ${localUids.length}`);
967
+ }
968
+ else {
969
+ const toDelete = localUids.filter(uid => !serverUids.has(uid));
970
+ const RECONCILE_DELETE_THRESHOLD = 0.5; // refuse to delete >50% in one pass
971
+ if (localUids.length > 0 && toDelete.length / localUids.length > RECONCILE_DELETE_THRESHOLD) {
972
+ console.log(` [api] ${accountId}/${folder.path}: reconcile refused — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%) — probably a sync bug, skipping`);
973
+ }
974
+ else {
975
+ for (const uid of toDelete) {
976
+ this.db.deleteMessage(accountId, uid);
977
+ this.bodyStore.deleteMessage(accountId, folder.id, uid).catch(() => { });
978
+ }
979
+ if (toDelete.length > 0)
980
+ console.log(` [api] ${accountId}/${folder.path}: ${toDelete.length} deleted`);
959
981
  }
960
982
  }
961
- if (deleted > 0)
962
- console.log(` [api] ${accountId}/${folder.path}: ${deleted} deleted`);
963
983
  }
964
984
  catch (e) {
965
985
  console.error(` [api] ${accountId}/${folder.path}: reconciliation error: ${e.message}`);
@@ -970,12 +990,15 @@ export class ImapManager extends EventEmitter {
970
990
  }
971
991
  /** Store API-fetched messages to DB */
972
992
  storeApiMessages(accountId, folderId, msgs, highestUid) {
993
+ // highestUid kept for signature compatibility but no longer used to
994
+ // filter — Gmail message IDs aren't monotonic, so `msg.uid <= highestUid`
995
+ // would drop brand-new messages whose hash happens to be smaller than
996
+ // the previous high. upsertMessage's primary-key dedup handles it.
997
+ void highestUid;
973
998
  let stored = 0;
974
999
  this.db.beginTransaction();
975
1000
  try {
976
1001
  for (const msg of msgs) {
977
- if (msg.uid <= highestUid)
978
- continue;
979
1002
  const flags = [];
980
1003
  if (msg.seen)
981
1004
  flags.push("\\Seen");
@@ -8,7 +8,11 @@ export declare class GmailApiProvider implements MailProvider {
8
8
  constructor(tokenProvider: () => Promise<string>);
9
9
  private fetch;
10
10
  listFolders(): Promise<ProviderFolder[]>;
11
- /** List message IDs matching a query, handling pagination */
11
+ /** List message IDs matching a query, handling pagination.
12
+ * IMPORTANT: on any error we throw — do NOT return a partial list, because
13
+ * callers use this for sync reconciliation and a short list would delete
14
+ * real messages from the local DB. Returning [] silently caused the
15
+ * "INBOX empty in mailx" bug when a rate-limit hit mid-pagination. */
12
16
  private listMessageIds;
13
17
  /** Batch-fetch message metadata or full content */
14
18
  private batchFetch;
@@ -93,10 +93,15 @@ export class GmailApiProvider {
93
93
  }
94
94
  return folders;
95
95
  }
96
- /** List message IDs matching a query, handling pagination */
96
+ /** List message IDs matching a query, handling pagination.
97
+ * IMPORTANT: on any error we throw — do NOT return a partial list, because
98
+ * callers use this for sync reconciliation and a short list would delete
99
+ * real messages from the local DB. Returning [] silently caused the
100
+ * "INBOX empty in mailx" bug when a rate-limit hit mid-pagination. */
97
101
  async listMessageIds(query, maxResults = 500) {
98
102
  const ids = [];
99
103
  let pageToken = "";
104
+ let truncated = false;
100
105
  while (true) {
101
106
  const params = new URLSearchParams({ q: query, maxResults: String(Math.min(maxResults - ids.length, 500)) });
102
107
  if (pageToken)
@@ -105,10 +110,17 @@ export class GmailApiProvider {
105
110
  for (const msg of data.messages || []) {
106
111
  ids.push(msg.id);
107
112
  }
108
- if (!data.nextPageToken || ids.length >= maxResults)
113
+ if (!data.nextPageToken)
109
114
  break;
115
+ if (ids.length >= maxResults) {
116
+ // Hit the caller's cap but the server has more. Flag it so
117
+ // reconcile-style callers can refuse to treat this as complete.
118
+ truncated = true;
119
+ break;
120
+ }
110
121
  pageToken = data.nextPageToken;
111
122
  }
123
+ ids._truncated = truncated;
112
124
  return ids;
113
125
  }
114
126
  /** Batch-fetch message metadata or full content */
@@ -170,12 +182,15 @@ export class GmailApiProvider {
170
182
  };
171
183
  }
172
184
  async fetchSince(folder, sinceUid, options = {}) {
173
- // Gmail doesn't have UIDs use date-based query for incremental
174
- // For now, fetch recent messages and let the caller filter by UID
185
+ // Gmail message IDs are hash-derived, NOT monotonic filtering by
186
+ // `uid > sinceUid` silently drops new messages whose hash happens to
187
+ // fall below the high-water mark. Fetch the most recent page and let
188
+ // upsertMessage dedupe by (account, folder, uid). The sinceUid arg is
189
+ // kept for interface compatibility but no longer used for filtering.
190
+ void sinceUid;
175
191
  const query = `in:${this.folderToLabel(folder)}`;
176
192
  const ids = await this.listMessageIds(query, 200);
177
- const messages = await this.batchFetch(ids, options);
178
- return messages.filter(m => m.uid > sinceUid);
193
+ return this.batchFetch(ids, options);
179
194
  }
180
195
  async fetchByDate(folder, since, before, options = {}, onChunk) {
181
196
  const afterDate = this.formatDate(since);
@@ -213,7 +228,11 @@ export class GmailApiProvider {
213
228
  async getUids(folder) {
214
229
  const query = `in:${this.folderToLabel(folder)}`;
215
230
  const ids = await this.listMessageIds(query, 10000);
216
- return ids.map(idToUid);
231
+ const result = ids.map(idToUid);
232
+ // Propagate the truncation flag so reconcile can refuse to delete.
233
+ if (ids._truncated)
234
+ result._truncated = true;
235
+ return result;
217
236
  }
218
237
  async close() {
219
238
  // No persistent connection to close