@bobfrankston/mailx 1.0.395 → 1.0.399

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.
@@ -447,6 +447,58 @@ class AndroidSyncManager {
447
447
  async undeleteMessage(accountId, uid, folderId) {
448
448
  this.db.queueSyncAction(accountId, "undelete", uid, folderId);
449
449
  }
450
+ /** Q112: drain queued move/flag/trash actions to the provider. Android is
451
+ * standalone — it pushes state changes directly to Gmail (or other
452
+ * provider) the same way desktop does. Called from the periodic 2-min
453
+ * tick above. `send` actions drain separately via `processSendQueue`. */
454
+ async processSyncActions(accountId) {
455
+ const provider = this.providers.get(accountId);
456
+ if (!provider)
457
+ return;
458
+ const pending = this.db.getPendingSyncActions(accountId)
459
+ .filter((a) => a.action !== "send");
460
+ if (pending.length === 0)
461
+ return;
462
+ const folders = this.db.getFolders(accountId);
463
+ const folderPath = (id) => {
464
+ const f = folders.find((x) => x.id === id);
465
+ return f?.path || null;
466
+ };
467
+ for (const p of pending) {
468
+ const path = folderPath(p.folderId);
469
+ if (!path) {
470
+ this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown folder ${p.folderId}`);
471
+ continue;
472
+ }
473
+ try {
474
+ if (p.action === "flags" && typeof provider.setFlags === "function") {
475
+ await provider.setFlags(path, p.uid, Array.isArray(p.flags) ? p.flags : (p.flags ? [p.flags] : []));
476
+ }
477
+ else if (p.action === "trash" && typeof provider.trashMessage === "function") {
478
+ await provider.trashMessage(path, p.uid);
479
+ }
480
+ else if (p.action === "move" && typeof provider.moveMessage === "function") {
481
+ const toId = p.targetFolderId;
482
+ const toPath = folderPath(toId);
483
+ if (!toPath) {
484
+ this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown target folder ${toId}`);
485
+ continue;
486
+ }
487
+ await provider.moveMessage(path, p.uid, toPath);
488
+ }
489
+ else {
490
+ this.db.failSyncActionByUid(accountId, p.action, p.uid, `provider does not support ${p.action}`);
491
+ continue;
492
+ }
493
+ this.db.completeSyncActionByUid(accountId, p.action, p.uid);
494
+ }
495
+ catch (e) {
496
+ const msg = e?.message || String(e);
497
+ console.error(`[sync-action] ${accountId} ${p.action} uid=${p.uid}: ${msg}`);
498
+ this.db.failSyncActionByUid(accountId, p.action, p.uid, msg);
499
+ }
500
+ }
501
+ }
450
502
  queueOutgoingLocal(accountId, rawMessage) {
451
503
  // Local-first: PERSIST to sync_actions before attempting the network
452
504
  // send, so a crash / offline / process kill between now and SMTP ACK
@@ -919,11 +971,16 @@ export async function initAndroid() {
919
971
  // Drain any stranded send-queue entries BEFORE first sync. A message
920
972
  // queued in a prior session (offline, crashed mid-send, process killed)
921
973
  // gets a retry as soon as we have accounts registered. Desktop parity.
974
+ // Q112 (2026-04-24): also drain move/flag/trash actions here — Android
975
+ // is standalone, not desktop-dependent, so it pushes state changes
976
+ // directly to the server the same way desktop does.
922
977
  for (const account of accounts) {
923
978
  if (!account.enabled)
924
979
  continue;
925
980
  syncManager.processSendQueue(account.id)
926
981
  .catch(e => console.error(`[android] processSendQueue ${account.id}: ${e.message}`));
982
+ syncManager.processSyncActions(account.id)
983
+ .catch(e => console.error(`[android] processSyncActions ${account.id}: ${e.message}`));
927
984
  }
928
985
  setTimeout(() => {
929
986
  syncManager.syncAll().catch(e => console.error(`[android] Sync error: ${e.message}`));
@@ -937,6 +994,8 @@ export async function initAndroid() {
937
994
  for (const account of db.getAccounts()) {
938
995
  syncManager.processSendQueue(account.id)
939
996
  .catch(e => console.error(`[android] retry ${account.id}: ${e.message}`));
997
+ syncManager.processSyncActions(account.id)
998
+ .catch(e => console.error(`[android] processSyncActions ${account.id}: ${e.message}`));
940
999
  }
941
1000
  syncManager.syncAll().catch(e => console.error(`[android] Periodic sync error: ${e.message}`));
942
1001
  }, SYNC_INTERVAL_MS);
@@ -998,6 +1057,7 @@ function installBridge() {
998
1057
  },
999
1058
  searchMessages: (query, page, pageSize) => service.search(query, page, pageSize),
1000
1059
  searchContacts: (query) => service.searchContacts(query),
1060
+ hasCcHistoryTo: (email) => ({ hasCc: service.hasCcHistoryTo?.(email) ?? false }),
1001
1061
  syncAll: async () => { await service.syncAll(); return { ok: true }; },
1002
1062
  syncAccount: async (accountId) => { await service.syncAccount(accountId); return { ok: true }; },
1003
1063
  getSyncPending: () => service.getSyncPending(),
@@ -94,6 +94,10 @@ export declare class WebMailxDB {
94
94
  source: string;
95
95
  useCount: number;
96
96
  }[];
97
+ /** Q49 heuristic: has the user ever sent to `recipientEmail` with a
98
+ * non-empty Cc? Scans Sent folder(s). Used by compose to auto-expand
99
+ * the Cc row on reply to a frequent-Cc'd recipient. */
100
+ hasCcHistoryTo(recipientEmail: string): boolean;
97
101
  searchMessages(query: string, page?: number, pageSize?: number, accountId?: string, folderId?: number): PagedResult<MessageEnvelope>;
98
102
  queueSyncAction(accountId: string, action: string, uid: number, folderId: number, extra?: {
99
103
  targetFolderId?: number;
@@ -483,11 +483,36 @@ export class WebMailxDB {
483
483
  return added;
484
484
  }
485
485
  searchContacts(query, limit = 10) {
486
+ query = (query || "").trim();
487
+ if (!query)
488
+ return [];
486
489
  const q = `%${query}%`;
487
490
  return this.all(`SELECT name, email, source, use_count as useCount FROM contacts
488
491
  WHERE email LIKE ? OR name LIKE ?
489
492
  ORDER BY use_count DESC, last_used DESC LIMIT ?`, [q, q, limit]);
490
493
  }
494
+ /** Q49 heuristic: has the user ever sent to `recipientEmail` with a
495
+ * non-empty Cc? Scans Sent folder(s). Used by compose to auto-expand
496
+ * the Cc row on reply to a frequent-Cc'd recipient. */
497
+ hasCcHistoryTo(recipientEmail) {
498
+ const email = (recipientEmail || "").trim().toLowerCase();
499
+ if (!email)
500
+ return false;
501
+ try {
502
+ const row = this.get(`
503
+ SELECT 1 FROM messages m
504
+ JOIN folders f ON m.folder_id = f.id
505
+ WHERE f.special_use = 'sent'
506
+ AND lower(m.to_json) LIKE ?
507
+ AND m.cc_json IS NOT NULL AND m.cc_json != '[]' AND m.cc_json != ''
508
+ LIMIT 1
509
+ `, [`%"${email}"%`]);
510
+ return !!row;
511
+ }
512
+ catch {
513
+ return false;
514
+ }
515
+ }
491
516
  // ── Search ──
492
517
  searchMessages(query, page = 1, pageSize = 50, accountId, folderId) {
493
518
  const offset = (page - 1) * pageSize;
@@ -48,6 +48,13 @@ export declare class SyncManager implements WebSyncManager {
48
48
  }[], targetFolderId: number): Promise<void>;
49
49
  moveMessageCrossAccount(): Promise<void>;
50
50
  undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void>;
51
+ /** Q112: drain queued move/flag/trash actions to the provider. Android is
52
+ * standalone — it pushes state changes to Gmail (or other provider) the
53
+ * same way desktop does, so local actions propagate without needing a
54
+ * desktop to relay them. Called from android-bootstrap on startup and
55
+ * every 2-min sync tick. `send` actions are drained separately by
56
+ * processSendQueue. */
57
+ processSyncActions(accountId: string): Promise<void>;
51
58
  markFolderRead(folderId: number): Promise<void>;
52
59
  emptyFolder(accountId: string, folderId: number): Promise<void>;
53
60
  queueOutgoingLocal(accountId: string, rawMessage: string): void;
@@ -329,6 +329,61 @@ export class SyncManager {
329
329
  async undeleteMessage(accountId, uid, folderId) {
330
330
  this.db.queueSyncAction(accountId, "undelete", uid, folderId);
331
331
  }
332
+ /** Q112: drain queued move/flag/trash actions to the provider. Android is
333
+ * standalone — it pushes state changes to Gmail (or other provider) the
334
+ * same way desktop does, so local actions propagate without needing a
335
+ * desktop to relay them. Called from android-bootstrap on startup and
336
+ * every 2-min sync tick. `send` actions are drained separately by
337
+ * processSendQueue. */
338
+ async processSyncActions(accountId) {
339
+ const provider = this.getProvider(accountId);
340
+ if (!provider)
341
+ return;
342
+ const pending = this.db.getPendingSyncActions(accountId)
343
+ .filter((a) => a.action !== "send");
344
+ if (pending.length === 0)
345
+ return;
346
+ const folders = this.db.getFolders(accountId);
347
+ const folderPath = (id) => {
348
+ const f = folders.find((x) => x.id === id);
349
+ return f?.path || null;
350
+ };
351
+ for (const p of pending) {
352
+ const path = folderPath(p.folderId);
353
+ if (!path) {
354
+ this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown folder ${p.folderId}`);
355
+ continue;
356
+ }
357
+ try {
358
+ if (p.action === "flags" && typeof provider.setFlags === "function") {
359
+ await provider.setFlags(path, p.uid, Array.isArray(p.flags) ? p.flags : (p.flags ? [p.flags] : []));
360
+ }
361
+ else if (p.action === "trash" && typeof provider.trashMessage === "function") {
362
+ await provider.trashMessage(path, p.uid);
363
+ }
364
+ else if (p.action === "move" && typeof provider.moveMessage === "function") {
365
+ const toId = p.targetFolderId;
366
+ const toPath = folderPath(toId);
367
+ if (!toPath) {
368
+ this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown target folder ${toId}`);
369
+ continue;
370
+ }
371
+ await provider.moveMessage(path, p.uid, toPath);
372
+ }
373
+ else {
374
+ // Unsupported action for this provider — don't loop forever.
375
+ this.db.failSyncActionByUid(accountId, p.action, p.uid, `provider does not support ${p.action}`);
376
+ continue;
377
+ }
378
+ this.db.completeSyncActionByUid(accountId, p.action, p.uid);
379
+ }
380
+ catch (e) {
381
+ const msg = e?.message || String(e);
382
+ console.error(`[sync-action] ${accountId} ${p.action} uid=${p.uid}: ${msg}`);
383
+ this.db.failSyncActionByUid(accountId, p.action, p.uid, msg);
384
+ }
385
+ }
386
+ }
332
387
  async markFolderRead(folderId) {
333
388
  this.db.markFolderRead(folderId);
334
389
  }
@@ -71,6 +71,10 @@ export declare class WebMailxService {
71
71
  }>;
72
72
  deleteDraft(accountId: string, draftUid: number): Promise<void>;
73
73
  searchContacts(query: string): any[];
74
+ /** Q49 heuristic mirror: true if the user has ever sent a message to
75
+ * `recipientEmail` that had a non-empty Cc field. Compose uses this to
76
+ * decide whether to auto-expand the Cc row on reply. */
77
+ hasCcHistoryTo(recipientEmail: string): boolean;
74
78
  getSettings(): Promise<any>;
75
79
  saveSettingsData(settings: any): Promise<void>;
76
80
  getStorageInfo(): {
@@ -437,10 +437,17 @@ export class WebMailxService {
437
437
  }
438
438
  // ── Contacts ──
439
439
  searchContacts(query) {
440
+ query = (query || "").trim();
440
441
  if (query.length < 1)
441
442
  return [];
442
443
  return this.db.searchContacts(query);
443
444
  }
445
+ /** Q49 heuristic mirror: true if the user has ever sent a message to
446
+ * `recipientEmail` that had a non-empty Cc field. Compose uses this to
447
+ * decide whether to auto-expand the Cc row on reply. */
448
+ hasCcHistoryTo(recipientEmail) {
449
+ return this.db.hasCcHistoryTo?.(recipientEmail) ?? false;
450
+ }
444
451
  // ── Settings ──
445
452
  async getSettings() {
446
453
  return loadSettings();
package/tdview.cmd ADDED
@@ -0,0 +1 @@
1
+ call mdview todo.md -pos 100,100,1 -size 900,1400