@bobfrankston/mailx 1.0.376 → 1.0.377

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.
@@ -188,11 +188,15 @@ export function hideCalendarSidebar() {
188
188
  catch { /* */ }
189
189
  }
190
190
  export function isCalendarSidebarOn() {
191
+ // Default is ON — user-reported 2026-04-23 that the sidebar should be
192
+ // visible by default, not hidden. An explicit "false" in localStorage
193
+ // still hides it (the user's stored preference wins).
191
194
  try {
192
- return localStorage.getItem(SIDEBAR_PREF) === "true";
195
+ const v = localStorage.getItem(SIDEBAR_PREF);
196
+ return v === null ? true : v !== "false";
193
197
  }
194
198
  catch {
195
- return false;
199
+ return true;
196
200
  }
197
201
  }
198
202
  /** Wire one-time event handlers + restore from localStorage. Safe to call
@@ -799,25 +799,24 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
799
799
  }
800
800
 
801
801
  /* S51 — calendar sidebar (Thunderbird Lightning Events & Tasks pane).
802
- Right-docked, fixed width, hidden by default. Toggled by View menu.
802
+ Right-docked, fixed width. Visible by default; user hides via View menu.
803
803
  Hides automatically on narrow screens (< 1100px) — Android uses the
804
- native calendar app. */
804
+ native calendar app. When hidden, the main grid does NOT reserve a column
805
+ for it, so there's no blank strip on the right. */
805
806
  .calendar-sidebar {
806
807
  grid-area: cal-side;
807
- width: 280px;
808
808
  border-left: 1px solid var(--color-border);
809
809
  background: var(--color-bg);
810
- display: flex;
810
+ display: none; /* Opt-in: only show when body.calendar-sidebar-on */
811
811
  flex-direction: column;
812
812
  overflow: hidden;
813
813
  font-size: var(--font-size-sm);
814
814
  }
815
+ body.calendar-sidebar-on .calendar-sidebar { display: flex; }
816
+
815
817
  @media (max-width: 1100px) {
816
- .calendar-sidebar { display: none; }
817
- }
818
- body.calendar-sidebar-on {
819
- /* Re-flow main grid to make room for the sidebar on the right.
820
- Folder-tree / message-list / message-viewer share the remaining area. */
818
+ .calendar-sidebar,
819
+ body.calendar-sidebar-on .calendar-sidebar { display: none; }
821
820
  }
822
821
  .cal-side-head {
823
822
  display: flex;
@@ -25,6 +25,18 @@ body {
25
25
  background: var(--color-bg);
26
26
  }
27
27
 
28
+ /* When the calendar sidebar is on, re-flow the grid to reserve a fourth
29
+ column on the right. When off, no column is reserved — the aside is
30
+ display:none and the grid stays three-column. */
31
+ body.calendar-sidebar-on {
32
+ grid-template-columns: var(--rail-width, 48px) var(--folder-width) 1fr var(--cal-side-width, 280px);
33
+ grid-template-areas:
34
+ "toolbar toolbar toolbar toolbar"
35
+ "alert alert alert alert"
36
+ "rail folders main cal-side"
37
+ "status status status status";
38
+ }
39
+
28
40
  .toolbar { grid-area: toolbar; }
29
41
  .icon-rail { grid-area: rail; }
30
42
  .folder-panel { grid-area: folders; display: flex; flex-direction: column; overflow: hidden; }
@@ -101,9 +113,13 @@ body {
101
113
  background: var(--color-accent);
102
114
  }
103
115
 
104
- /* Responsive: mid-width (tablets, foldables) — keep rail + list + viewer; folders overlay */
116
+ /* Responsive: mid-width (tablets, foldables) — keep rail + list + viewer; folders overlay.
117
+ Calendar sidebar is hidden in this tier by its own media query, and the
118
+ body grid does NOT include a cal-side column here — the .calendar-sidebar-on
119
+ body class override above is shadowed by this mid-width rule. */
105
120
  @media (max-width: 1100px) and (min-width: 769px) {
106
- body {
121
+ body,
122
+ body.calendar-sidebar-on {
107
123
  grid-template-columns: var(--rail-width, 48px) 1fr;
108
124
  grid-template-rows: var(--toolbar-height) auto 1fr var(--statusbar-height);
109
125
  grid-template-areas:
@@ -157,7 +173,8 @@ body {
157
173
  #status-version { display: none; }
158
174
  }
159
175
  @media (max-width: 768px), (max-height: 600px) {
160
- body {
176
+ body,
177
+ body.calendar-sidebar-on {
161
178
  grid-template-columns: 1fr;
162
179
  grid-template-rows: var(--toolbar-height) auto 1fr var(--statusbar-height);
163
180
  grid-template-areas:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.376",
3
+ "version": "1.0.377",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -430,23 +430,35 @@ class AndroidSyncManager {
430
430
  this.db.queueSyncAction(accountId, "undelete", uid, folderId);
431
431
  }
432
432
  queueOutgoingLocal(accountId, rawMessage) {
433
- // Two paths, both real (no stubs that pretend success — see programming.md
434
- // rule "Stubs MUST NOT appear successful"):
435
- // - Gmail accounts: POST to users.messages.send (Gmail handles SMTP +
436
- // auto-files into Sent label).
437
- // - Non-Gmail accounts: smtp-direct over BridgeTransport (mailxapi.tcp).
438
- // Caller (web-service.send) is sync-returning; we kick off the network
439
- // request and surface success/failure via events. Compose UI listens for
440
- // sendError/sendComplete.
433
+ // Local-first: PERSIST to sync_actions before attempting the network
434
+ // send, so a crash / offline / process kill between now and SMTP ACK
435
+ // doesn't drop the message. Desktop parity PC writes `.ltr` to disk
436
+ // before calling SMTP; Android writes a sync_actions row to sql.js +
437
+ // IndexedDB (which saveDbToIdb persists on scheduleSave). Unique neg
438
+ // uid = Date.now() to avoid colliding with real message UIDs and to
439
+ // give us a stable tracking key through the async send pipeline.
440
+ //
441
+ // User-flagged 2026-04-23: "sending on android, like on the PC must
442
+ // be queued." Equivalent of PC's `~/.mailx/outbox/<acct>/*.ltr`.
443
+ const queueUid = -Date.now();
444
+ this.db.queueSyncAction(accountId, "send", queueUid, -1, { rawMessage });
445
+ this.attemptSend(accountId, queueUid, rawMessage);
446
+ }
447
+ /** Kick off a send for a message that's already in the queue. Called by
448
+ * queueOutgoingLocal on a fresh submit AND by processSendQueue on
449
+ * startup / periodic tick for anything stranded from a prior run. */
450
+ attemptSend(accountId, queueUid, rawMessage) {
441
451
  const provider = this.getProvider(accountId);
442
452
  if (provider && typeof provider.sendRaw === "function") {
443
453
  provider.sendRaw(rawMessage)
444
454
  .then((result) => {
445
455
  console.log(`[send] ${accountId}: sent via Gmail API (id=${result.id})`);
456
+ this.db.completeSyncActionByUid(accountId, "send", queueUid);
446
457
  emitEvent({ type: "sendComplete", accountId, messageId: result.id });
447
458
  })
448
459
  .catch((e) => {
449
460
  console.error(`[send] ${accountId}: Gmail send failed: ${e.message}`);
461
+ this.db.failSyncActionByUid(accountId, "send", queueUid, e.message || String(e));
450
462
  emitEvent({ type: "sendError", accountId, error: e.message });
451
463
  });
452
464
  return;
@@ -458,8 +470,9 @@ class AndroidSyncManager {
458
470
  if (!row) {
459
471
  const e = "Unknown account";
460
472
  console.error(`[send] ${accountId}: ${e}`);
473
+ this.db.failSyncActionByUid(accountId, "send", queueUid, e);
461
474
  emitEvent({ type: "sendError", accountId, error: e });
462
- throw new Error(e);
475
+ return;
463
476
  }
464
477
  let account;
465
478
  try {
@@ -467,26 +480,41 @@ class AndroidSyncManager {
467
480
  }
468
481
  catch {
469
482
  const e = "Account config malformed";
483
+ this.db.failSyncActionByUid(accountId, "send", queueUid, e);
470
484
  emitEvent({ type: "sendError", accountId, error: e });
471
- throw new Error(e);
485
+ return;
472
486
  }
473
487
  if (!account.smtp) {
474
488
  const e = "No SMTP config for this account";
475
489
  console.error(`[send] ${accountId}: ${e}`);
490
+ this.db.failSyncActionByUid(accountId, "send", queueUid, e);
476
491
  emitEvent({ type: "sendError", accountId, error: e });
477
- throw new Error(e);
492
+ return;
478
493
  }
479
- // Fire async — same pattern as Gmail path above.
480
494
  this.sendViaSmtpDirect(accountId, account, rawMessage)
481
495
  .then((result) => {
482
496
  console.log(`[send] ${accountId}: sent via SMTP (${result.accepted.length} accepted, ${result.rejected.length} rejected)`);
497
+ this.db.completeSyncActionByUid(accountId, "send", queueUid);
483
498
  emitEvent({ type: "sendComplete", accountId });
484
499
  })
485
500
  .catch((e) => {
486
501
  console.error(`[send] ${accountId}: SMTP send failed: ${e.message}`);
502
+ this.db.failSyncActionByUid(accountId, "send", queueUid, e.message || String(e));
487
503
  emitEvent({ type: "sendError", accountId, error: e.message });
488
504
  });
489
505
  }
506
+ /** Drain any stranded 'send' queue entries — called at startup and on
507
+ * each periodic sync tick so messages queued while offline or stranded
508
+ * by a crash get a retry. Each row keeps its queueUid as tracking key. */
509
+ async processSendQueue(accountId) {
510
+ const pending = this.db.getPendingSyncActions(accountId).filter(a => a.action === "send" && a.rawMessage);
511
+ if (pending.length === 0)
512
+ return;
513
+ console.log(`[send] ${accountId}: draining ${pending.length} queued message(s)`);
514
+ for (const p of pending) {
515
+ this.attemptSend(accountId, p.uid, p.rawMessage);
516
+ }
517
+ }
490
518
  /** Build SMTP config from account, send via smtp-direct over BridgeTransport. */
491
519
  async sendViaSmtpDirect(accountId, account, raw) {
492
520
  const SMTP_PORT_STARTTLS = 587;
@@ -870,6 +898,15 @@ export async function initAndroid() {
870
898
  }
871
899
  }
872
900
  installBridge();
901
+ // Drain any stranded send-queue entries BEFORE first sync. A message
902
+ // queued in a prior session (offline, crashed mid-send, process killed)
903
+ // gets a retry as soon as we have accounts registered. Desktop parity.
904
+ for (const account of accounts) {
905
+ if (!account.enabled)
906
+ continue;
907
+ syncManager.processSendQueue(account.id)
908
+ .catch(e => console.error(`[android] processSendQueue ${account.id}: ${e.message}`));
909
+ }
873
910
  setTimeout(() => {
874
911
  syncManager.syncAll().catch(e => console.error(`[android] Sync error: ${e.message}`));
875
912
  }, 1000);
@@ -878,6 +915,11 @@ export async function initAndroid() {
878
915
  setInterval(() => {
879
916
  console.log("[sync] periodic poll");
880
917
  vlog("periodic sync poll");
918
+ // Retry any failed/stranded sends every poll tick
919
+ for (const account of db.getAccounts()) {
920
+ syncManager.processSendQueue(account.id)
921
+ .catch(e => console.error(`[android] retry ${account.id}: ${e.message}`));
922
+ }
881
923
  syncManager.syncAll().catch(e => console.error(`[android] Periodic sync error: ${e.message}`));
882
924
  }, SYNC_INTERVAL_MS);
883
925
  // Immediate sync when app comes back to foreground (e.g. user switches from
@@ -103,6 +103,12 @@ export declare class WebMailxDB {
103
103
  getPendingSyncActions(accountId: string): any[];
104
104
  completeSyncAction(id: number): void;
105
105
  failSyncAction(id: number, error: string): void;
106
+ /** Delete a queued sync action by (accountId, action, uid) — used by the
107
+ * send path which tracks queued sends via a unique negative uid rather
108
+ * than threading the row id through the async send pipeline. */
109
+ completeSyncActionByUid(accountId: string, action: string, uid: number): void;
110
+ /** Mark a send-queue action failed by uid — same tracking-key rationale. */
111
+ failSyncActionByUid(accountId: string, action: string, uid: number, error: string): void;
106
112
  getPendingSyncCount(accountId: string): number;
107
113
  getTotalPendingSyncCount(): number;
108
114
  /** Reset the entire database */
@@ -478,6 +478,16 @@ export class WebMailxDB {
478
478
  failSyncAction(id, error) {
479
479
  this.run("UPDATE sync_actions SET attempts = attempts + 1, last_error = ? WHERE id = ?", [error, id]);
480
480
  }
481
+ /** Delete a queued sync action by (accountId, action, uid) — used by the
482
+ * send path which tracks queued sends via a unique negative uid rather
483
+ * than threading the row id through the async send pipeline. */
484
+ completeSyncActionByUid(accountId, action, uid) {
485
+ this.run("DELETE FROM sync_actions WHERE account_id = ? AND action = ? AND uid = ?", [accountId, action, uid]);
486
+ }
487
+ /** Mark a send-queue action failed by uid — same tracking-key rationale. */
488
+ failSyncActionByUid(accountId, action, uid, error) {
489
+ this.run("UPDATE sync_actions SET attempts = attempts + 1, last_error = ? WHERE account_id = ? AND action = ? AND uid = ?", [error, accountId, action, uid]);
490
+ }
481
491
  getPendingSyncCount(accountId) {
482
492
  const r = this.get("SELECT COUNT(*) as cnt FROM sync_actions WHERE account_id = ?", [accountId]);
483
493
  return r?.cnt || 0;