@bobfrankston/mailx 1.0.394 → 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.
@@ -153,10 +153,15 @@ body.calendar-sidebar-on {
153
153
  "status status";
154
154
  }
155
155
 
156
- /* Folder panel: overlay slide-in from left, sitting just to the right of the rail */
156
+ /* Folder panel: FULLY off-screen when closed previously `calc(48px -
157
+ 280px) = -232px` left a 48 px tail showing behind the rail, which on
158
+ Android (where the rail isn't always rendered) leaked a strip of
159
+ folder-row badges at the left edge of the viewport. Now the closed
160
+ panel is flush off-screen at `-280px` and the `.open` slide-in starts
161
+ at the rail's right edge. */
157
162
  .folder-panel {
158
163
  position: fixed;
159
- left: calc(var(--rail-width, 48px) - 280px);
164
+ left: -280px;
160
165
  top: var(--toolbar-height);
161
166
  bottom: var(--statusbar-height);
162
167
  width: 280px;
@@ -168,14 +173,19 @@ body.calendar-sidebar-on {
168
173
  }
169
174
  .folder-panel.open { left: var(--rail-width, 48px); }
170
175
 
171
- /* Show hamburger */
172
- #btn-menu { display: inline-flex !important; }
176
+ /* Medium tier has the rail visible permanently — no hamburger needed.
177
+ Show the folder-toggle (📁) so the user can still reveal the folder
178
+ tree on demand. */
179
+ #btn-menu { display: none !important; }
180
+ #btn-folder-toggle { display: inline-flex !important; }
173
181
  }
174
182
 
175
183
  /* Responsive: narrow OR short viewport — single panel navigation */
176
184
  @media (max-width: 768px), (max-height: 600px) {
177
- /* Hide preview snippet under message subjectsave space */
178
- .ml-preview { display: none; }
185
+ /* Preview snippets remain visible on narrowuser-controlled via the
186
+ View menu's "Preview snippets" checkbox. Previously this rule forced
187
+ them hidden on any narrow viewport, so Android users never saw the
188
+ checkbox take effect. */
179
189
  /* Column headers (From/Date/Subject) take space without being useful on narrow */
180
190
  .ml-header { display: none; }
181
191
  /* Current folder name shown above the list, Dovecot-style */
@@ -208,12 +218,25 @@ body.calendar-sidebar-on {
208
218
  "status";
209
219
  }
210
220
 
211
- /* Rail hidden on narrow its commands fold into the hamburger / toolbar.
212
- Future work: a slide-in rail behind the hamburger so power-users on phone
213
- can still reach calendar/contacts/etc. */
214
- .icon-rail { display: none; }
221
+ /* Rail on narrow: slide-in drawer triggered by the hamburger (☰). The
222
+ rail is the entry point to Inbox / All-Inboxes / Contacts / Calendar /
223
+ Tasks / Settings without this drawer, narrow-tier users couldn't
224
+ reach any of those. */
225
+ .icon-rail {
226
+ display: flex;
227
+ position: fixed;
228
+ left: -64px;
229
+ top: var(--toolbar-height);
230
+ bottom: var(--statusbar-height);
231
+ z-index: 60;
232
+ transition: left 0.2s ease;
233
+ box-shadow: 2px 0 8px rgba(0,0,0,0.3);
234
+ }
235
+ .icon-rail.open { left: 0; }
215
236
 
216
- /* Folder panel: overlay slide-in from left */
237
+ /* Folder panel: slide-in drawer triggered by the folder icon (📁).
238
+ Kept separate from the rail drawer so the user can have folders open
239
+ while the rail is closed, or vice versa — matching the desktop flow. */
217
240
  .folder-panel {
218
241
  position: fixed;
219
242
  left: -280px;
@@ -246,8 +269,10 @@ body.calendar-sidebar-on {
246
269
  }
247
270
  .message-list.narrow-hidden { display: none; }
248
271
 
249
- /* Show hamburger always on narrow */
272
+ /* Hamburger opens the rail drawer; folder-toggle opens the folder drawer.
273
+ Both always visible on narrow so the user can reach either. */
250
274
  #btn-menu { display: inline-flex !important; }
275
+ #btn-folder-toggle { display: inline-flex !important; }
251
276
  /* Back button: only show when viewer is active (message list hidden) */
252
277
  #btn-back { display: none !important; }
253
278
  .message-viewer.narrow-active ~ * #btn-back,
@@ -280,7 +305,8 @@ body.calendar-sidebar-on {
280
305
  }
281
306
  }
282
307
 
283
- /* Hide hamburger and back on wide screens (folder panel always visible) */
308
+ /* Hide hamburger, folder-toggle, and back on wide screens (folder panel
309
+ permanent column, rail permanent column, no drawer toggles needed). */
284
310
  @media (min-width: 1101px) {
285
- #btn-menu, #btn-back { display: none !important; }
311
+ #btn-menu, #btn-folder-toggle, #btn-back { display: none !important; }
286
312
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.394",
3
+ "version": "1.0.399",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -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
@@ -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