@bobfrankston/mailx 1.0.395 → 1.0.405

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.
@@ -158,6 +158,10 @@ export declare class MailxService {
158
158
  }>;
159
159
  deleteDraft(accountId: string, draftUid: number, draftId?: string): Promise<void>;
160
160
  searchContacts(query: string): any[];
161
+ /** Q49: boolean hint for compose to auto-expand Cc when replying to this
162
+ * address. True when at least one past sent message to the same recipient
163
+ * had a non-empty Cc field. */
164
+ hasCcHistoryTo(email: string): boolean;
161
165
  syncGoogleContacts(): Promise<void>;
162
166
  seedContacts(): number;
163
167
  /** Explicit add to address book — used by the right-click "Add to contacts"
@@ -1351,6 +1351,12 @@ export class MailxService {
1351
1351
  return [];
1352
1352
  return this.db.searchContacts(query);
1353
1353
  }
1354
+ /** Q49: boolean hint for compose to auto-expand Cc when replying to this
1355
+ * address. True when at least one past sent message to the same recipient
1356
+ * had a non-empty Cc field. */
1357
+ hasCcHistoryTo(email) {
1358
+ return this.db.hasCcHistoryTo(email);
1359
+ }
1354
1360
  async syncGoogleContacts() {
1355
1361
  await this.imapManager.syncAllContacts();
1356
1362
  }
@@ -140,6 +140,8 @@ async function dispatchAction(svc, action, p) {
140
140
  return svc.search(p.query, p.page, p.pageSize, p.scope, p.accountId, p.folderId);
141
141
  case "searchContacts":
142
142
  return svc.searchContacts(p.query);
143
+ case "hasCcHistoryTo":
144
+ return { hasCc: svc.hasCcHistoryTo(p.email) };
143
145
  case "addContact":
144
146
  return { ok: svc.addContact(p.name, p.email) };
145
147
  case "listContacts":
@@ -19,6 +19,14 @@ export declare class MailxDB {
19
19
  hasSentMessage(messageId: string): boolean;
20
20
  /** Record a successfully sent message so future attempts are skipped. */
21
21
  recordSent(messageId: string, accountId: string, subject: string, recipients: string[]): void;
22
+ /** Q49 heuristic: has the user ever sent a message to `recipientEmail`
23
+ * that had a non-empty Cc field? Used by compose to auto-expand the Cc
24
+ * input when replying to someone who customarily gets Cc'd with others.
25
+ * Query scans only Sent folders (special_use='sent') and matches the
26
+ * recipient's address inside `to_json` via LIKE. No special index — the
27
+ * Sent folder's row count is typically a few thousand at most; acceptable
28
+ * on the compose-open path. */
29
+ hasCcHistoryTo(recipientEmail: string): boolean;
22
30
  /** Mark a Message-ID as locally-deleted for an account. No-op if messageId
23
31
  * is empty (e.g. provider stripped the header) — without a stable id we
24
32
  * can't check against future sync results anyway. */
@@ -338,6 +338,32 @@ export class MailxDB {
338
338
  console.error(` [sent_log] failed to record ${messageId}: ${e.message}`);
339
339
  }
340
340
  }
341
+ /** Q49 heuristic: has the user ever sent a message to `recipientEmail`
342
+ * that had a non-empty Cc field? Used by compose to auto-expand the Cc
343
+ * input when replying to someone who customarily gets Cc'd with others.
344
+ * Query scans only Sent folders (special_use='sent') and matches the
345
+ * recipient's address inside `to_json` via LIKE. No special index — the
346
+ * Sent folder's row count is typically a few thousand at most; acceptable
347
+ * on the compose-open path. */
348
+ hasCcHistoryTo(recipientEmail) {
349
+ const email = (recipientEmail || "").trim().toLowerCase();
350
+ if (!email)
351
+ return false;
352
+ try {
353
+ const row = this.db.prepare(`
354
+ SELECT 1 FROM messages m
355
+ JOIN folders f ON m.folder_id = f.id
356
+ WHERE f.special_use = 'sent'
357
+ AND lower(m.to_json) LIKE ?
358
+ AND m.cc_json IS NOT NULL AND m.cc_json != '[]' AND m.cc_json != ''
359
+ LIMIT 1
360
+ `).get(`%"${email}"%`);
361
+ return !!row;
362
+ }
363
+ catch {
364
+ return false;
365
+ }
366
+ }
341
367
  // ── Tombstones (local-delete record so server echo can't resurrect) ──
342
368
  /** Mark a Message-ID as locally-deleted for an account. No-op if messageId
343
369
  * is empty (e.g. provider stripped the header) — without a stable id we
@@ -853,7 +879,9 @@ export class MailxDB {
853
879
  const rows = this.db.prepare(`SELECT m.*, EXISTS(
854
880
  SELECT 1 FROM sync_actions sa
855
881
  WHERE sa.account_id = m.account_id AND sa.uid = m.uid
856
- ) AS pending
882
+ ) AS pending,
883
+ (SELECT COUNT(DISTINCT account_id) FROM messages m2
884
+ WHERE m2.message_id = m.message_id AND m.message_id != '') AS dupeCount
857
885
  FROM messages m WHERE m.folder_id IN (${placeholders})
858
886
  ORDER BY m.date DESC LIMIT ? OFFSET ?`).all(...folderIds, pageSize, offset);
859
887
  const items = rows.map(r => ({
@@ -876,6 +904,11 @@ export class MailxDB {
876
904
  preview: r.preview,
877
905
  bodyPath: r.body_path || "",
878
906
  pending: !!r.pending,
907
+ // >=2 means the same message-id exists under another account in
908
+ // the local DB (delivered to both accounts, or a mailing-list
909
+ // Bcc). The unified-inbox UI shows a small ⇆ badge on these
910
+ // rows so the user knows "this is a copy of the same message".
911
+ dupeCount: r.dupeCount | 0,
879
912
  }));
880
913
  return { items, total, page, pageSize };
881
914
  }
@@ -174,9 +174,19 @@ class AndroidSyncManager {
174
174
  const THROTTLE_MS = 150;
175
175
  const RATE_LIMIT_PAUSE_MS = 30000;
176
176
  const ERROR_BUDGET = 10;
177
+ const CONCURRENCY = 2; // S62: 2 in-flight per account
177
178
  let totalFetched = 0;
178
179
  let errors = 0;
179
180
  let announced = false;
181
+ // S62: INBOX always first. Within each folder the DB returns rows
182
+ // most-recent-first (PRIMARY KEY order), so newest unfetched INBOX
183
+ // mail wins the queue. A slow label (`[Gmail]/Jerrry`, etc.) can't
184
+ // starve INBOX any more.
185
+ const folderPriority = (folderId) => {
186
+ const f = this.db.getFolders(accountId).find((x) => x.id === folderId);
187
+ return f?.specialUse === "inbox" ? 0 : 1;
188
+ };
189
+ let rateLimitCooldownUntil = 0;
180
190
  while (true) {
181
191
  const missing = this.db.getMessagesWithoutBody(accountId, BATCH_SIZE);
182
192
  if (missing.length === 0)
@@ -186,46 +196,70 @@ class AndroidSyncManager {
186
196
  vlog(`prefetch ${accountId} start: ${missing.length}+ pending`);
187
197
  announced = true;
188
198
  }
199
+ // Sort this batch INBOX-first. getMessagesWithoutBody doesn't
200
+ // know the priority, and re-querying per folder would multiply
201
+ // the SELECTs. One in-memory sort is cheap.
202
+ missing.sort((a, b) => folderPriority(a.folderId) - folderPriority(b.folderId));
189
203
  let progressedThisBatch = false;
190
- for (const m of missing) {
191
- // Sync the DB path if the body is already in IndexedDB — common
192
- // when upgrading from a build that didn't set body_path on cache.
193
- if (await this.bodyStore.hasMessage(accountId, m.folderId, m.uid)) {
194
- this.db.updateBodyPath(accountId, m.uid, `idb:${accountId}/${m.folderId}/${m.uid}`);
195
- progressedThisBatch = true;
196
- continue;
197
- }
198
- try {
199
- const result = await this.fetchMessageBody(accountId, m.folderId, m.uid);
200
- if (result) {
201
- totalFetched++;
202
- progressedThisBatch = true;
203
- emitEvent({ type: "bodyCached", accountId, uid: m.uid, folderId: m.folderId });
204
+ let batchAborted = false;
205
+ // Bounded-concurrency worker pool. Each worker pulls the next
206
+ // unclaimed item from `missing`. Shared flags (errors,
207
+ // rateLimitCooldownUntil, progressedThisBatch) are updated
208
+ // inside the loop — sql.js is single-threaded so there's no
209
+ // actual race on reads/writes.
210
+ let cursor = 0;
211
+ const worker = async () => {
212
+ while (cursor < missing.length) {
213
+ if (batchAborted)
214
+ return;
215
+ if (errors >= ERROR_BUDGET)
216
+ return;
217
+ const idx = cursor++;
218
+ const m = missing[idx];
219
+ // Honor rate-limit cooldown across workers.
220
+ const now = Date.now();
221
+ if (rateLimitCooldownUntil > now) {
222
+ await new Promise(r => setTimeout(r, rateLimitCooldownUntil - now));
204
223
  }
205
- else {
206
- errors++;
224
+ if (await this.bodyStore.hasMessage(accountId, m.folderId, m.uid)) {
225
+ this.db.updateBodyPath(accountId, m.uid, `idb:${accountId}/${m.folderId}/${m.uid}`);
226
+ progressedThisBatch = true;
227
+ continue;
207
228
  }
208
- }
209
- catch (e) {
210
- errors++;
211
- const msg = String(e?.message || "");
212
- if (/429|rate|too many/i.test(msg)) {
213
- console.log(`[prefetch] ${accountId}: rate-limited pausing ${RATE_LIMIT_PAUSE_MS / 1000}s`);
214
- await new Promise(r => setTimeout(r, RATE_LIMIT_PAUSE_MS));
229
+ try {
230
+ const result = await this.fetchMessageBody(accountId, m.folderId, m.uid);
231
+ if (result) {
232
+ totalFetched++;
233
+ progressedThisBatch = true;
234
+ emitEvent({ type: "bodyCached", accountId, uid: m.uid, folderId: m.folderId });
235
+ }
236
+ else {
237
+ errors++;
238
+ }
215
239
  }
216
- else {
217
- console.error(`[prefetch] ${accountId}/${m.uid}: ${msg}`);
240
+ catch (e) {
241
+ errors++;
242
+ const msg = String(e?.message || "");
243
+ if (/429|rate|too many/i.test(msg)) {
244
+ console.log(`[prefetch] ${accountId}: rate-limited — pausing ${RATE_LIMIT_PAUSE_MS / 1000}s`);
245
+ rateLimitCooldownUntil = Date.now() + RATE_LIMIT_PAUSE_MS;
246
+ }
247
+ else {
248
+ console.error(`[prefetch] ${accountId}/${m.uid}: ${msg}`);
249
+ }
218
250
  }
251
+ // Throttle kept per-request to spread load on flaky
252
+ // phone networks; concurrency-2 means effective request
253
+ // rate is ~1 per THROTTLE_MS/2.
254
+ await new Promise(r => setTimeout(r, THROTTLE_MS));
219
255
  }
220
- if (errors >= ERROR_BUDGET) {
221
- console.error(`[prefetch] ${accountId}: stopping after ${errors} errors (${totalFetched} cached)`);
222
- vlog(`prefetch ${accountId} aborted: ${errors} errors, ${totalFetched} cached`);
223
- return;
224
- }
225
- await new Promise(r => setTimeout(r, THROTTLE_MS));
256
+ };
257
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, missing.length) }, () => worker()));
258
+ if (errors >= ERROR_BUDGET) {
259
+ console.error(`[prefetch] ${accountId}: stopping after ${errors} errors (${totalFetched} cached)`);
260
+ vlog(`prefetch ${accountId} aborted: ${errors} errors, ${totalFetched} cached`);
261
+ return;
226
262
  }
227
- // If a full batch made no progress, bail out to avoid an infinite
228
- // loop on messages the server can't deliver.
229
263
  if (!progressedThisBatch) {
230
264
  console.warn(`[prefetch] ${accountId}: batch made no progress, stopping`);
231
265
  break;
@@ -447,6 +481,58 @@ class AndroidSyncManager {
447
481
  async undeleteMessage(accountId, uid, folderId) {
448
482
  this.db.queueSyncAction(accountId, "undelete", uid, folderId);
449
483
  }
484
+ /** Q112: drain queued move/flag/trash actions to the provider. Android is
485
+ * standalone — it pushes state changes directly to Gmail (or other
486
+ * provider) the same way desktop does. Called from the periodic 2-min
487
+ * tick above. `send` actions drain separately via `processSendQueue`. */
488
+ async processSyncActions(accountId) {
489
+ const provider = this.providers.get(accountId);
490
+ if (!provider)
491
+ return;
492
+ const pending = this.db.getPendingSyncActions(accountId)
493
+ .filter((a) => a.action !== "send");
494
+ if (pending.length === 0)
495
+ return;
496
+ const folders = this.db.getFolders(accountId);
497
+ const folderPath = (id) => {
498
+ const f = folders.find((x) => x.id === id);
499
+ return f?.path || null;
500
+ };
501
+ for (const p of pending) {
502
+ const path = folderPath(p.folderId);
503
+ if (!path) {
504
+ this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown folder ${p.folderId}`);
505
+ continue;
506
+ }
507
+ try {
508
+ if (p.action === "flags" && typeof provider.setFlags === "function") {
509
+ await provider.setFlags(path, p.uid, Array.isArray(p.flags) ? p.flags : (p.flags ? [p.flags] : []));
510
+ }
511
+ else if (p.action === "trash" && typeof provider.trashMessage === "function") {
512
+ await provider.trashMessage(path, p.uid);
513
+ }
514
+ else if (p.action === "move" && typeof provider.moveMessage === "function") {
515
+ const toId = p.targetFolderId;
516
+ const toPath = folderPath(toId);
517
+ if (!toPath) {
518
+ this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown target folder ${toId}`);
519
+ continue;
520
+ }
521
+ await provider.moveMessage(path, p.uid, toPath);
522
+ }
523
+ else {
524
+ this.db.failSyncActionByUid(accountId, p.action, p.uid, `provider does not support ${p.action}`);
525
+ continue;
526
+ }
527
+ this.db.completeSyncActionByUid(accountId, p.action, p.uid);
528
+ }
529
+ catch (e) {
530
+ const msg = e?.message || String(e);
531
+ console.error(`[sync-action] ${accountId} ${p.action} uid=${p.uid}: ${msg}`);
532
+ this.db.failSyncActionByUid(accountId, p.action, p.uid, msg);
533
+ }
534
+ }
535
+ }
450
536
  queueOutgoingLocal(accountId, rawMessage) {
451
537
  // Local-first: PERSIST to sync_actions before attempting the network
452
538
  // send, so a crash / offline / process kill between now and SMTP ACK
@@ -858,76 +944,87 @@ export async function initAndroid() {
858
944
  }
859
945
  await syncManager.addAccount(account);
860
946
  }
861
- // Connect to GDrive using the Gmail token read shared accounts.jsonc
862
- if (gmailTokenProvider) {
863
- setGDriveTokenProvider(gmailTokenProvider);
864
- try {
865
- console.log("[android] Looking up GDrive mailx folder...");
866
- const folderId = await findGDriveMailxFolder(gmailTokenProvider);
867
- if (!folderId) {
868
- console.warn("[android] GDrive mailx folder not found");
869
- }
870
- else {
871
- setGDriveFolderId(folderId);
872
- console.log(`[android] GDrive mailx folder: ${folderId}`);
873
- // DEBUG: list all files in the folder
874
- try {
875
- const tk = await gmailTokenProvider();
876
- const lr = await fetch(`https://www.googleapis.com/drive/v3/files?q='${folderId}'+in+parents+and+trashed%3Dfalse&fields=files(id,name,mimeType,owners(emailAddress))`, { headers: { "Authorization": `Bearer ${tk}` } });
877
- if (lr.ok) {
878
- const ld = await lr.json();
879
- const names = (ld.files || []).map((f) => `${f.name}(${f.owners?.[0]?.emailAddress || "?"})`).join(",");
880
- console.log(`[android] Folder contents: ${ld.files?.length || 0} files [${names}]`);
881
- }
882
- else {
883
- console.warn(`[android] List folder failed: ${lr.status}`);
884
- }
885
- }
886
- catch (e) {
887
- console.warn(`[android] List debug: ${e.message}`);
888
- }
889
- // Read accounts directly from GDrive (bypass IndexedDB cache)
890
- console.log("[android] Reading accounts.jsonc from GDrive...");
891
- const gdriveAccounts = await loadAccountsFromCloud();
892
- console.log(`[android] GDrive returned ${gdriveAccounts.length} accounts: ${gdriveAccounts.map(a => a.id).join(",")}`);
893
- if (gdriveAccounts.length > 0) {
894
- // Use canonical GDrive accounts (upsert handles overwrites)
895
- accounts = gdriveAccounts;
896
- for (const account of accounts) {
897
- vlog(`init: registering ${account.id} email=${account.email} enabled=${account.enabled} imap=${JSON.stringify(account.imap)}`);
898
- if (!account.enabled) {
899
- vlog(`init: ${account.id} disabled, skipping`);
900
- continue;
901
- }
902
- const domain = account.email?.split("@")[1]?.toLowerCase() || "";
903
- if (domain === "gmail.com" || domain === "googlemail.com") {
904
- syncManager.setTokenProvider(account.id, createNativeTokenProvider(account.email));
905
- }
906
- await syncManager.addAccount(account);
907
- }
908
- console.log(`[android] Loaded ${accounts.length} accounts from GDrive`);
909
- }
910
- // Register this Android device in clients.jsonc
911
- await registerDeviceInGDrive(gmailTokenProvider, folderId, accounts.map(a => a.id));
912
- }
913
- }
914
- catch (e) {
915
- console.warn(`[android] GDrive access failed: ${e.message}`);
916
- }
917
- }
947
+ // Install the mailxapi bridge + drain pending queues IMMEDIATELY using
948
+ // the local-cache account list. UI shouldn't wait on GDrive (which can
949
+ // be slow on cold network) before becoming actionable. GDrive
950
+ // reconciliation (below) runs in the background and re-registers fresh
951
+ // accounts when it returns.
918
952
  installBridge();
919
- // Drain any stranded send-queue entries BEFORE first sync. A message
920
- // queued in a prior session (offline, crashed mid-send, process killed)
921
- // gets a retry as soon as we have accounts registered. Desktop parity.
922
953
  for (const account of accounts) {
923
954
  if (!account.enabled)
924
955
  continue;
925
956
  syncManager.processSendQueue(account.id)
926
957
  .catch(e => console.error(`[android] processSendQueue ${account.id}: ${e.message}`));
958
+ syncManager.processSyncActions(account.id)
959
+ .catch(e => console.error(`[android] processSyncActions ${account.id}: ${e.message}`));
927
960
  }
961
+ // First sync from local accounts on a tiny delay so the UI gets to paint.
928
962
  setTimeout(() => {
929
963
  syncManager.syncAll().catch(e => console.error(`[android] Sync error: ${e.message}`));
930
964
  }, 1000);
965
+ // GDrive reconciliation runs in the background — accounts.jsonc on the
966
+ // shared cloud may have been edited from another device, so we re-pull
967
+ // and re-register if it differs from the cached copy. The user can
968
+ // already see and use mail by the time this resolves.
969
+ if (gmailTokenProvider) {
970
+ const tp = gmailTokenProvider;
971
+ (async () => {
972
+ setGDriveTokenProvider(tp);
973
+ try {
974
+ console.log("[android] Looking up GDrive mailx folder…");
975
+ const folderId = await findGDriveMailxFolder(tp);
976
+ if (!folderId) {
977
+ console.warn("[android] GDrive mailx folder not found");
978
+ }
979
+ else {
980
+ setGDriveFolderId(folderId);
981
+ console.log(`[android] GDrive mailx folder: ${folderId}`);
982
+ // DEBUG: list all files in the folder
983
+ try {
984
+ const tk = await tp();
985
+ const lr = await fetch(`https://www.googleapis.com/drive/v3/files?q='${folderId}'+in+parents+and+trashed%3Dfalse&fields=files(id,name,mimeType,owners(emailAddress))`, { headers: { "Authorization": `Bearer ${tk}` } });
986
+ if (lr.ok) {
987
+ const ld = await lr.json();
988
+ const names = (ld.files || []).map((f) => `${f.name}(${f.owners?.[0]?.emailAddress || "?"})`).join(",");
989
+ console.log(`[android] Folder contents: ${ld.files?.length || 0} files [${names}]`);
990
+ }
991
+ else {
992
+ console.warn(`[android] List folder failed: ${lr.status}`);
993
+ }
994
+ }
995
+ catch (e) {
996
+ console.warn(`[android] List debug: ${e.message}`);
997
+ }
998
+ // Read accounts directly from GDrive (bypass IndexedDB cache)
999
+ console.log("[android] Reading accounts.jsonc from GDrive...");
1000
+ const gdriveAccounts = await loadAccountsFromCloud();
1001
+ console.log(`[android] GDrive returned ${gdriveAccounts.length} accounts: ${gdriveAccounts.map(a => a.id).join(",")}`);
1002
+ if (gdriveAccounts.length > 0) {
1003
+ // Use canonical GDrive accounts (upsert handles overwrites)
1004
+ accounts = gdriveAccounts;
1005
+ for (const account of accounts) {
1006
+ vlog(`init: registering ${account.id} email=${account.email} enabled=${account.enabled} imap=${JSON.stringify(account.imap)}`);
1007
+ if (!account.enabled) {
1008
+ vlog(`init: ${account.id} disabled, skipping`);
1009
+ continue;
1010
+ }
1011
+ const domain = account.email?.split("@")[1]?.toLowerCase() || "";
1012
+ if (domain === "gmail.com" || domain === "googlemail.com") {
1013
+ syncManager.setTokenProvider(account.id, createNativeTokenProvider(account.email));
1014
+ }
1015
+ await syncManager.addAccount(account);
1016
+ }
1017
+ console.log(`[android] Loaded ${accounts.length} accounts from GDrive`);
1018
+ }
1019
+ // Register this Android device in clients.jsonc
1020
+ await registerDeviceInGDrive(tp, folderId, accounts.map(a => a.id));
1021
+ }
1022
+ }
1023
+ catch (e) {
1024
+ console.warn(`[android] GDrive access failed: ${e.message}`);
1025
+ }
1026
+ })();
1027
+ }
931
1028
  // Periodic re-sync every 2 minutes (no IDLE on Android, so poll)
932
1029
  const SYNC_INTERVAL_MS = 2 * 60 * 1000;
933
1030
  setInterval(() => {
@@ -937,6 +1034,8 @@ export async function initAndroid() {
937
1034
  for (const account of db.getAccounts()) {
938
1035
  syncManager.processSendQueue(account.id)
939
1036
  .catch(e => console.error(`[android] retry ${account.id}: ${e.message}`));
1037
+ syncManager.processSyncActions(account.id)
1038
+ .catch(e => console.error(`[android] processSyncActions ${account.id}: ${e.message}`));
940
1039
  }
941
1040
  syncManager.syncAll().catch(e => console.error(`[android] Periodic sync error: ${e.message}`));
942
1041
  }, SYNC_INTERVAL_MS);
@@ -998,6 +1097,7 @@ function installBridge() {
998
1097
  },
999
1098
  searchMessages: (query, page, pageSize) => service.search(query, page, pageSize),
1000
1099
  searchContacts: (query) => service.searchContacts(query),
1100
+ hasCcHistoryTo: (email) => ({ hasCc: service.hasCcHistoryTo?.(email) ?? false }),
1001
1101
  syncAll: async () => { await service.syncAll(); return { ok: true }; },
1002
1102
  syncAccount: async (accountId) => { await service.syncAccount(accountId); return { ok: true }; },
1003
1103
  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
package/unwedge.cmd ADDED
@@ -0,0 +1 @@
1
+ rmdir C:\Users\Bob\.claude\session-env\5a053d1d-7856-4e74-9b2b-23a4e3262aed /s /q