@bobfrankston/mailx 1.0.221 → 1.0.223

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/client/app.js CHANGED
@@ -483,6 +483,7 @@ function forwardBody(msg) {
483
483
  return `<br><div class="reply"><p>---------- Forwarded message ----------<br>From: ${from}<br>Date: ${date}<br>Subject: ${msg.subject}<br>To: ${to}</p>${body}</div>`;
484
484
  }
485
485
  let lastDeleted = null;
486
+ let lastMoved = null;
486
487
  let undoTimeout = null;
487
488
  async function deleteSelectedMessages() {
488
489
  const selected = getSelectedMessages();
@@ -548,6 +549,49 @@ async function undoDelete() {
548
549
  console.error(`Undo failed: ${e.message}`);
549
550
  }
550
551
  }
552
+ async function undoMove() {
553
+ if (!lastMoved)
554
+ return;
555
+ const { messages } = lastMoved;
556
+ const statusSync = document.getElementById("status-sync");
557
+ try {
558
+ // Group by (sourceAccountId, sourceFolderId) and move each group back
559
+ const byDest = new Map();
560
+ for (const m of messages) {
561
+ const key = `${m.accountId}:${m.sourceFolderId}`;
562
+ if (!byDest.has(key))
563
+ byDest.set(key, { accountId: m.accountId, folderId: m.sourceFolderId, uids: [] });
564
+ byDest.get(key).uids.push(m.uid);
565
+ }
566
+ const { moveMessages, moveMessage } = await import("./lib/api-client.js");
567
+ for (const group of byDest.values()) {
568
+ if (group.uids.length === 1)
569
+ await moveMessage(group.accountId, group.uids[0], group.folderId);
570
+ else
571
+ await moveMessages(group.accountId, group.uids, group.folderId);
572
+ }
573
+ if (statusSync)
574
+ statusSync.textContent = `Undid move of ${messages.length} message${messages.length !== 1 ? "s" : ""}`;
575
+ lastMoved = null;
576
+ if (undoTimeout)
577
+ clearTimeout(undoTimeout);
578
+ reloadCurrentFolder();
579
+ }
580
+ catch (e) {
581
+ console.error(`Undo move failed: ${e.message}`);
582
+ if (statusSync)
583
+ statusSync.textContent = `Undo move failed: ${e.message}`;
584
+ }
585
+ }
586
+ // Listen for the "mailx-moved" custom event emitted by folder-tree's drop
587
+ // handler so Ctrl+Z can reverse the most recent move.
588
+ document.addEventListener("mailx-moved", (e) => {
589
+ lastMoved = e.detail;
590
+ lastDeleted = null; // Ctrl+Z undoes whichever came last
591
+ if (undoTimeout)
592
+ clearTimeout(undoTimeout);
593
+ undoTimeout = setTimeout(() => { lastMoved = null; }, 60000);
594
+ });
551
595
  document.getElementById("btn-delete")?.addEventListener("click", deleteSelectedMessages);
552
596
  document.getElementById("btn-compose")?.addEventListener("click", () => openCompose("new"));
553
597
  document.getElementById("btn-reply")?.addEventListener("click", () => openCompose("reply"));
@@ -909,9 +953,13 @@ document.addEventListener("keydown", (e) => {
909
953
  e.preventDefault();
910
954
  deleteSelectedMessages();
911
955
  }
912
- // Ctrl+Z = Undo delete
956
+ // Ctrl+Z = Undo the most recent delete or move
913
957
  if (e.ctrlKey && e.key === "z") {
914
- if (lastDeleted) {
958
+ if (lastMoved) {
959
+ e.preventDefault();
960
+ undoMove();
961
+ }
962
+ else if (lastDeleted) {
915
963
  e.preventDefault();
916
964
  undoDelete();
917
965
  }
@@ -325,13 +325,22 @@ function renderNode(node, container, depth) {
325
325
  }
326
326
  const moved = toMove.length;
327
327
  if (statusEl)
328
- statusEl.textContent = `Moved ${moved} message${moved > 1 ? "s" : ""} to ${node.name}`;
328
+ statusEl.textContent = `Moved ${moved} message${moved > 1 ? "s" : ""} to ${node.name} — Ctrl+Z to undo`;
329
329
  // Remove from shared state — list and viewer update automatically
330
330
  const { removeMessages } = await import("../lib/message-state.js");
331
331
  removeMessages(toMove);
332
332
  const treeContainer = document.getElementById("folder-tree");
333
333
  if (treeContainer)
334
334
  loadFolderTree(treeContainer);
335
+ // Notify app.ts so Ctrl+Z can undo this move. Each entry carries
336
+ // its ORIGINAL folderId/accountId so we know where to move back to.
337
+ document.dispatchEvent(new CustomEvent("mailx-moved", {
338
+ detail: {
339
+ messages: toMove.map(m => ({ accountId: m.accountId, uid: m.uid, sourceFolderId: m.folderId })),
340
+ targetAccountId: node.accountId,
341
+ targetFolderId: node.id,
342
+ },
343
+ }));
335
344
  }
336
345
  catch (err) {
337
346
  console.error(`Move failed: ${err.message}`);
@@ -2,7 +2,7 @@
2
2
  * Message viewer component -- displays full message in sandboxed iframe.
3
3
  * Subscribes to message-state: clears when selected becomes null.
4
4
  */
5
- import { getMessage, updateFlags, allowRemoteContent, getAttachment } from "../lib/api-client.js";
5
+ import { getMessage, updateFlags, allowRemoteContent, getAttachment, addContact } from "../lib/api-client.js";
6
6
  import { showContextMenu } from "./context-menu.js";
7
7
  import * as state from "../lib/message-state.js";
8
8
  /** Currently displayed message (for reply/forward) */
@@ -173,20 +173,37 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
173
173
  toEl.textContent += ` Cc: ${msg.cc.map(formatAddr).join(", ")}`;
174
174
  }
175
175
  headerEl.querySelector(".mv-subject").textContent = msg.subject;
176
- // Right-click on email addresses in header
177
- const allAddresses = [msg.from, ...(msg.to || []), ...(msg.cc || [])].filter((a) => a?.address);
176
+ // Right-click on email addresses in header: copy name, copy address,
177
+ // copy both, add to contacts, plus reply actions for the whole message.
178
178
  for (const el of [fromEl, toEl]) {
179
179
  el.addEventListener("contextmenu", (e) => {
180
180
  e.preventDefault();
181
181
  const me = e;
182
182
  const items = [];
183
- for (const addr of (el === fromEl ? [msg.from] : [...(msg.to || []), ...(msg.cc || [])])) {
183
+ const addrs = el === fromEl ? [msg.from] : [...(msg.to || []), ...(msg.cc || [])];
184
+ for (const addr of addrs) {
184
185
  if (!addr?.address)
185
186
  continue;
186
- const display = addr.name ? `${addr.name} <${addr.address}>` : addr.address;
187
- items.push({ label: `Copy: ${display}`, action: () => navigator.clipboard.writeText(addr.address) });
187
+ const name = addr.name || "";
188
+ const both = name ? `${name} <${addr.address}>` : addr.address;
189
+ if (name) {
190
+ items.push({ label: `Copy name: ${name}`, action: () => navigator.clipboard.writeText(name) });
191
+ }
192
+ items.push({ label: `Copy address: ${addr.address}`, action: () => navigator.clipboard.writeText(addr.address) });
193
+ if (name) {
194
+ items.push({ label: `Copy both: ${both}`, action: () => navigator.clipboard.writeText(both) });
195
+ }
196
+ items.push({
197
+ label: `Add to contacts: ${addr.address}`,
198
+ action: async () => {
199
+ try {
200
+ await addContact(name, addr.address);
201
+ }
202
+ catch { /* ignore */ }
203
+ },
204
+ });
205
+ items.push({ label: "", action: () => { }, separator: true });
188
206
  }
189
- items.push({ label: "", action: () => { }, separator: true });
190
207
  items.push({ label: "Reply", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "reply" } })) });
191
208
  items.push({ label: "Reply All", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "replyAll" } })) });
192
209
  items.push({ label: "Forward", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "forward" } })) });
@@ -294,6 +294,36 @@ body {
294
294
  }
295
295
  .ql-editor, .tt-content .tiptap { position: relative; }
296
296
 
297
+ /* Attachment chips above the editor */
298
+ .compose-attachments {
299
+ padding: var(--gap-xs) var(--gap-md);
300
+ display: flex;
301
+ flex-wrap: wrap;
302
+ gap: var(--gap-xs);
303
+ background: var(--color-bg-surface);
304
+ border-bottom: 1px solid var(--color-border);
305
+ }
306
+ .compose-att-chip {
307
+ display: inline-flex;
308
+ align-items: center;
309
+ gap: 6px;
310
+ padding: 3px 8px;
311
+ border-radius: 999px;
312
+ background: color-mix(in oklch, var(--color-accent) 15%, transparent);
313
+ color: var(--color-text);
314
+ font-size: var(--font-size-sm);
315
+ }
316
+ .compose-att-chip button {
317
+ background: none;
318
+ border: none;
319
+ color: var(--color-text-muted);
320
+ cursor: pointer;
321
+ padding: 0;
322
+ font-size: 1em;
323
+ line-height: 1;
324
+ }
325
+ .compose-att-chip button:hover { color: oklch(0.65 0.2 25); }
326
+
297
327
  /* Link editor modal (Ctrl+K / toolbar link button) */
298
328
  .mailx-modal-backdrop {
299
329
  position: fixed;
@@ -37,9 +37,11 @@
37
37
  <div class="compose-toolbar">
38
38
  <button class="tb-btn" id="btn-send">Send</button>
39
39
  <button class="tb-btn" id="btn-attach">Attach</button>
40
+ <input type="file" id="compose-file" multiple hidden>
40
41
  <button class="tb-btn" id="btn-discard">Discard</button>
41
42
  <span id="compose-status" class="compose-status"></span>
42
43
  </div>
44
+ <div id="compose-attachments" class="compose-attachments" hidden></div>
43
45
  <div id="compose-editor"></div>
44
46
  </body>
45
47
  </html>
@@ -302,6 +302,7 @@ let draftDebounceTimer = null;
302
302
  let lastDraftContent = "";
303
303
  let draftSaving = false; // prevent concurrent saves
304
304
  let draftSaveFailed = false; // surfaced in the compose status tag
305
+ const attachments = [];
305
306
  function showDraftStatus(text, isError) {
306
307
  const status = document.getElementById("compose-status");
307
308
  if (!status)
@@ -418,6 +419,7 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
418
419
  subject: subjectInput.value,
419
420
  bodyHtml: editor.getHtml(),
420
421
  bodyText: editor.getText(),
422
+ attachments: attachments.map(a => ({ filename: a.filename, mimeType: a.mimeType, dataBase64: a.dataBase64 })),
421
423
  };
422
424
  try {
423
425
  await sendMessage(body);
@@ -495,6 +497,68 @@ async function handleCloseRequest() {
495
497
  document.getElementById("btn-discard")?.addEventListener("click", () => {
496
498
  handleCloseRequest();
497
499
  });
500
+ // ── Attachments ──
501
+ const fileInput = document.getElementById("compose-file");
502
+ const attEl = document.getElementById("compose-attachments");
503
+ function renderAttachmentChips() {
504
+ attEl.innerHTML = "";
505
+ if (attachments.length === 0) {
506
+ attEl.hidden = true;
507
+ return;
508
+ }
509
+ attEl.hidden = false;
510
+ for (let i = 0; i < attachments.length; i++) {
511
+ const a = attachments[i];
512
+ const chip = document.createElement("span");
513
+ chip.className = "compose-att-chip";
514
+ chip.innerHTML = `\uD83D\uDCCE ${escapeHtml(a.filename)} (${formatSize(a.size)}) `;
515
+ const rm = document.createElement("button");
516
+ rm.type = "button";
517
+ rm.title = "Remove attachment";
518
+ rm.textContent = "\u2715";
519
+ rm.addEventListener("click", () => {
520
+ attachments.splice(i, 1);
521
+ renderAttachmentChips();
522
+ });
523
+ chip.appendChild(rm);
524
+ attEl.appendChild(chip);
525
+ }
526
+ }
527
+ function escapeHtml(s) {
528
+ return s.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
529
+ }
530
+ function formatSize(n) {
531
+ if (n < 1024)
532
+ return `${n} B`;
533
+ if (n < 1024 * 1024)
534
+ return `${(n / 1024).toFixed(1)} KB`;
535
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
536
+ }
537
+ document.getElementById("btn-attach")?.addEventListener("click", () => {
538
+ fileInput?.click();
539
+ });
540
+ fileInput?.addEventListener("change", async () => {
541
+ if (!fileInput.files)
542
+ return;
543
+ for (const file of Array.from(fileInput.files)) {
544
+ const buf = await file.arrayBuffer();
545
+ // base64 the whole thing — mailx-service builds the multipart/mixed
546
+ let binary = "";
547
+ const bytes = new Uint8Array(buf);
548
+ for (let i = 0; i < bytes.length; i++)
549
+ binary += String.fromCharCode(bytes[i]);
550
+ const dataBase64 = btoa(binary);
551
+ attachments.push({
552
+ filename: file.name,
553
+ mimeType: file.type || "application/octet-stream",
554
+ size: file.size,
555
+ dataBase64,
556
+ });
557
+ }
558
+ fileInput.value = "";
559
+ renderAttachmentChips();
560
+ scheduleDraftSave();
561
+ });
498
562
  // ── Save and close (X button from parent) ──
499
563
  window.addEventListener("compose-save-and-close", () => {
500
564
  handleCloseRequest();
@@ -146,6 +146,9 @@ export function repairAccounts() {
146
146
  export function deleteDraft(accountId, draftUid, draftId) {
147
147
  return ipc().deleteDraft?.(accountId, draftUid, draftId);
148
148
  }
149
+ export function addContact(name, email) {
150
+ return ipc().addContact?.(name, email);
151
+ }
149
152
  export function setupAccount(name, email, password) {
150
153
  return ipc().setupAccount?.(name, email, password);
151
154
  }
@@ -97,6 +97,9 @@
97
97
  searchMessages: function(query, page, pageSize) {
98
98
  return callNode("searchMessages", { query: query, page: page, pageSize: pageSize });
99
99
  },
100
+ addContact: function(name, email) {
101
+ return callNode("addContact", { name: name, email: email });
102
+ },
100
103
  searchContacts: function(query) {
101
104
  return callNode("searchContacts", { query: query });
102
105
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.221",
3
+ "version": "1.0.223",
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.283",
27
+ "@bobfrankston/msger": "^0.1.285",
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.283",
81
+ "@bobfrankston/msger": "^0.1.285",
82
82
  "@capacitor/android": "^8.3.0",
83
83
  "@capacitor/cli": "^8.3.0",
84
84
  "@capacitor/core": "^8.3.0",
@@ -188,9 +188,12 @@ export declare class ImapManager extends EventEmitter {
188
188
  private saveSendingCopy;
189
189
  /** Queue a message for sending. Tries IMAP Outbox, falls back to local file. */
190
190
  queueOutgoing(accountId: string, rawMessage: string | Buffer): Promise<void>;
191
- /** Process local file queue — send from outbox/ only (IMAP-unreachable fallback).
192
- * Do NOT scan sending/<acct>/queued/ that was causing every sent message to be
193
- * re-APPENDed to the IMAP Outbox on the next tick and delivered twice. */
191
+ /** Process local file queue — scan both outbox/ (IMAP-unreachable fallback)
192
+ * and sending/<acct>/queued/ (manual drop-in, crash recovery). The earlier
193
+ * double-send bug was caused by queueOutgoing() WRITING a debug copy to
194
+ * sending/queued/ on every send — that write is gone now, so scanning the
195
+ * directory is safe again. Any legitimate files that land there (crash
196
+ * recovery, manual drop) will get sent. */
194
197
  private processLocalQueue;
195
198
  /** Send a raw RFC 2822 message via SMTP for a given account */
196
199
  private sendRawViaSMTP;
@@ -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");
@@ -1802,15 +1825,21 @@ export class ImapManager extends EventEmitter {
1802
1825
  fs.writeFileSync(path.join(localQueue, filename), rawMessage);
1803
1826
  console.log(` [outbox] Saved locally: ${filename}`);
1804
1827
  }
1805
- /** Process local file queue — send from outbox/ only (IMAP-unreachable fallback).
1806
- * Do NOT scan sending/<acct>/queued/ that was causing every sent message to be
1807
- * re-APPENDed to the IMAP Outbox on the next tick and delivered twice. */
1828
+ /** Process local file queue — scan both outbox/ (IMAP-unreachable fallback)
1829
+ * and sending/<acct>/queued/ (manual drop-in, crash recovery). The earlier
1830
+ * double-send bug was caused by queueOutgoing() WRITING a debug copy to
1831
+ * sending/queued/ on every send — that write is gone now, so scanning the
1832
+ * directory is safe again. Any legitimate files that land there (crash
1833
+ * recovery, manual drop) will get sent. */
1808
1834
  async processLocalQueue(accountId) {
1809
1835
  const outboxDir = path.join(getConfigDir(), "outbox", accountId);
1836
+ const queuedDir = path.join(getConfigDir(), "sending", accountId, "queued");
1810
1837
  const filesToSend = [];
1811
- if (fs.existsSync(outboxDir)) {
1812
- for (const file of fs.readdirSync(outboxDir).filter(f => f.endsWith(".ltr") || f.endsWith(".eml"))) {
1813
- filesToSend.push({ dir: outboxDir, file });
1838
+ for (const dir of [outboxDir, queuedDir]) {
1839
+ if (!fs.existsSync(dir))
1840
+ continue;
1841
+ for (const file of fs.readdirSync(dir).filter(f => f.endsWith(".ltr") || f.endsWith(".eml"))) {
1842
+ filesToSend.push({ dir, file });
1814
1843
  }
1815
1844
  }
1816
1845
  if (filesToSend.length === 0)
@@ -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
@@ -55,6 +55,10 @@ export declare class MailxService {
55
55
  searchContacts(query: string): any[];
56
56
  syncGoogleContacts(): Promise<void>;
57
57
  seedContacts(): number;
58
+ /** Explicit add to address book — used by the right-click "Add to contacts"
59
+ * action on From/To/Cc addresses in the message viewer. Just calls the same
60
+ * validated upsert path as recordSentAddress. */
61
+ addContact(name: string, email: string): boolean;
58
62
  getSettings(): any;
59
63
  saveSettings(settings: any): void;
60
64
  getStorageInfo(): {
@@ -378,16 +378,51 @@ export class MailxService {
378
378
  // Generate a unique Message-ID (required for threading, dedup, and RFC compliance)
379
379
  const domain = account.email.split("@")[1] || "mailx.local";
380
380
  const messageId = `<${Date.now()}.${Math.random().toString(36).slice(2)}@${domain}>`;
381
- const headers = [
381
+ const hasAttachments = Array.isArray(msg.attachments) && msg.attachments.length > 0;
382
+ const commonHeaders = [
382
383
  `From: ${fromHeader}`, `To: ${to}`,
383
384
  cc ? `Cc: ${cc}` : null, bcc ? `Bcc: ${bcc}` : null,
384
385
  `Subject: ${msg.subject}`, `Date: ${new Date().toUTCString()}`,
385
386
  `Message-ID: ${messageId}`,
386
387
  msg.inReplyTo ? `In-Reply-To: ${msg.inReplyTo}` : null,
387
388
  msg.references?.length ? `References: ${msg.references.join(" ")}` : null,
388
- `MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: quoted-printable`,
389
- ].filter(h => h !== null).join("\r\n");
390
- const rawMessage = `${headers}\r\n\r\n${bodyEncoded}`;
389
+ `MIME-Version: 1.0`,
390
+ ].filter(h => h !== null);
391
+ let rawMessage;
392
+ if (hasAttachments) {
393
+ // multipart/mixed with the body + one base64 attachment part per file.
394
+ // Each attachment chunk is wrapped at 76-char lines per RFC 2045.
395
+ const boundary = `mailx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
396
+ const wrap76 = (s) => s.replace(/.{1,76}/g, m => m).match(/.{1,76}/g)?.join("\r\n") || s;
397
+ const parts = [];
398
+ parts.push(`--${boundary}\r\n` +
399
+ `Content-Type: text/html; charset=UTF-8\r\n` +
400
+ `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
401
+ `${bodyEncoded}\r\n`);
402
+ for (const att of msg.attachments) {
403
+ const filename = (att.filename || "attachment").replace(/[\r\n"]/g, "_");
404
+ const mime = att.mimeType || "application/octet-stream";
405
+ const wrapped = wrap76(att.dataBase64 || "");
406
+ parts.push(`--${boundary}\r\n` +
407
+ `Content-Type: ${mime}; name="${filename}"\r\n` +
408
+ `Content-Disposition: attachment; filename="${filename}"\r\n` +
409
+ `Content-Transfer-Encoding: base64\r\n\r\n` +
410
+ `${wrapped}\r\n`);
411
+ }
412
+ const headers = [
413
+ ...commonHeaders,
414
+ `Content-Type: multipart/mixed; boundary="${boundary}"`,
415
+ ].join("\r\n");
416
+ rawMessage = `${headers}\r\n\r\n${parts.join("")}--${boundary}--\r\n`;
417
+ }
418
+ else {
419
+ const headers = [
420
+ ...commonHeaders,
421
+ `Content-Type: text/html; charset=UTF-8`,
422
+ `Content-Transfer-Encoding: quoted-printable`,
423
+ ].join("\r\n");
424
+ rawMessage = `${headers}\r\n\r\n${bodyEncoded}`;
425
+ }
391
426
  this.imapManager.queueOutgoingLocal(account.id, rawMessage);
392
427
  console.log(` Queued locally: ${msg.subject} via ${account.id} from ${fromHeader}`);
393
428
  for (const addr of msg.to)
@@ -615,6 +650,15 @@ export class MailxService {
615
650
  console.log(` Seeded ${added} contacts from message history`);
616
651
  return added;
617
652
  }
653
+ /** Explicit add to address book — used by the right-click "Add to contacts"
654
+ * action on From/To/Cc addresses in the message viewer. Just calls the same
655
+ * validated upsert path as recordSentAddress. */
656
+ addContact(name, email) {
657
+ if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email))
658
+ return false;
659
+ this.db.recordSentAddress(name || "", email);
660
+ return true;
661
+ }
618
662
  // ── Settings ──
619
663
  getSettings() {
620
664
  return loadSettings();
@@ -94,6 +94,8 @@ async function dispatchAction(svc, action, p) {
94
94
  return svc.search(p.query, p.page, p.pageSize, p.scope, p.accountId, p.folderId);
95
95
  case "searchContacts":
96
96
  return svc.searchContacts(p.query);
97
+ case "addContact":
98
+ return { ok: svc.addContact(p.name, p.email) };
97
99
  // Settings
98
100
  case "getSettings":
99
101
  return svc.getSettings();