@bobfrankston/mailx 1.0.377 → 1.0.378

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.
@@ -58,7 +58,7 @@ async function fetchUpcoming(from) {
58
58
  const local = loadLocalEvents();
59
59
  let google = [];
60
60
  try {
61
- const primary = await getPrimaryAccount();
61
+ const primary = await getPrimaryAccount("calendar");
62
62
  if (primary?.email) {
63
63
  // The OAuth token is held by the service-side OAuthTokenManager;
64
64
  // calendar fetch goes through a server-side proxy method (not
@@ -370,7 +370,7 @@ function restoreSelection(body, savedUid) {
370
370
  /** Show a floating list of all messages in a thread when the pill is clicked.
371
371
  * Each entry in the popup selects that message in the viewer when clicked.
372
372
  * This is simpler than inline expansion and avoids duplicating the row builder. */
373
- async function showThreadPopup(pillEl, headMsg) {
373
+ export async function showThreadPopup(pillEl, headMsg) {
374
374
  // Remove any existing popup
375
375
  document.querySelectorAll(".ml-thread-popup").forEach(el => el.remove());
376
376
  let thread = [];
@@ -464,6 +464,11 @@ function appendMessages(body, accountId, items) {
464
464
  // action (move/flag/delete) hasn't been ACK'd by the server yet.
465
465
  if (msg.pending)
466
466
  row.classList.add("pending-reconcile");
467
+ // Reply-row marker: messages with In-Reply-To are replies. Shows a
468
+ // subtle left-border accent so the eye can pick out threaded replies
469
+ // without enabling full thread grouping.
470
+ if (msg.inReplyTo)
471
+ row.classList.add("is-reply");
467
472
  row.dataset.uid = String(msg.uid);
468
473
  row.dataset.accountId = msgAccountId;
469
474
  row.dataset.folderId = String(msg.folderId);
@@ -600,6 +605,37 @@ function appendMessages(body, accountId, items) {
600
605
  }
601
606
  });
602
607
  row.addEventListener("dragend", () => row.classList.remove("dragging"));
608
+ // ── Q66: long-press on touch → context menu ──
609
+ // Mirrors right-click on the phone where right-click isn't a thing.
610
+ // Cancelled by any touchmove or touchend before the threshold.
611
+ let longPressTimer = null;
612
+ const LONG_PRESS_MS = 550;
613
+ row.addEventListener("touchstart", (e) => {
614
+ const t = e.touches[0];
615
+ if (!t)
616
+ return;
617
+ const cx = t.clientX, cy = t.clientY;
618
+ if (longPressTimer)
619
+ clearTimeout(longPressTimer);
620
+ longPressTimer = setTimeout(() => {
621
+ longPressTimer = null;
622
+ // Synthesize a contextmenu event so the existing handler below
623
+ // owns all the menu logic — no per-event duplication.
624
+ const ev = new MouseEvent("contextmenu", {
625
+ clientX: cx, clientY: cy, bubbles: true, cancelable: true,
626
+ });
627
+ row.dispatchEvent(ev);
628
+ }, LONG_PRESS_MS);
629
+ }, { passive: true });
630
+ const cancelLongPress = () => {
631
+ if (longPressTimer) {
632
+ clearTimeout(longPressTimer);
633
+ longPressTimer = null;
634
+ }
635
+ };
636
+ row.addEventListener("touchmove", cancelLongPress, { passive: true });
637
+ row.addEventListener("touchend", cancelLongPress, { passive: true });
638
+ row.addEventListener("touchcancel", cancelLongPress, { passive: true });
603
639
  // ── Right-click context menu ──
604
640
  row.addEventListener("contextmenu", (e) => {
605
641
  e.preventDefault();
@@ -395,6 +395,23 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
395
395
  unsubBtn.hidden = true;
396
396
  }
397
397
  }
398
+ // View Thread button — opens the thread popup from the message list
399
+ // so the user can see all messages in the conversation. Works from
400
+ // the viewer even when thread-grouping is off.
401
+ const threadBtn = document.getElementById("mv-view-thread");
402
+ if (threadBtn) {
403
+ const tid = msg.threadId || "";
404
+ if (tid) {
405
+ threadBtn.hidden = false;
406
+ threadBtn.onclick = async () => {
407
+ const { showThreadPopup } = await import("./message-list.js");
408
+ await showThreadPopup(threadBtn, { accountId, threadId: tid });
409
+ };
410
+ }
411
+ else {
412
+ threadBtn.hidden = true;
413
+ }
414
+ }
398
415
  // View Source button — shows .eml file path
399
416
  const srcBtn = document.getElementById("mv-view-source");
400
417
  if (srcBtn) {
package/client/index.html CHANGED
@@ -134,6 +134,7 @@
134
134
  <button class="tb-btn" id="btn-spam" title="Mark as spam — move to configured spam folder" hidden>⚠</button>
135
135
  <button class="tb-btn" id="btn-flag" title="Flag">⚑</button>
136
136
  <button class="tb-btn" id="btn-mark-unread" title="Mark unread (R)">◉</button>
137
+ <button class="tb-btn" id="mv-view-thread" title="View thread (conversation)" hidden>💬</button>
137
138
  <span style="flex:1"></span>
138
139
  <button class="mv-action mv-action-primary" id="mv-edit-draft" hidden>Edit & Send</button>
139
140
  <a class="mv-unsubscribe" id="mv-unsubscribe" hidden>Unsubscribe</a>
@@ -134,10 +134,12 @@ export function getSyncPending() {
134
134
  export function getDiagnostics() {
135
135
  return ipc().getDiagnostics?.() ?? Promise.resolve([]);
136
136
  }
137
- /** Account marked `primary: true` in accounts.jsonc used by Calendar /
138
- * Tasks / Contacts to pick which Google account's data to show. */
139
- export function getPrimaryAccount() {
140
- return ipc().getPrimaryAccount?.() ?? Promise.resolve(null);
137
+ /** Account that supplies `feature` data (calendar / tasks / contacts).
138
+ * Resolution: per-feature primary flag catch-all `primary` first account.
139
+ * Pass e.g. "calendar" to honor `primaryCalendar:true` overrides; omit for
140
+ * back-compat single-flag behavior. */
141
+ export function getPrimaryAccount(feature) {
142
+ return ipc().getPrimaryAccount?.(feature) ?? Promise.resolve(null);
141
143
  }
142
144
  export function getOutboxStatus() {
143
145
  return ipc().getOutboxStatus();
@@ -160,7 +160,7 @@
160
160
  getSyncPending: function() { return callNode("getSyncPending"); },
161
161
  getOutboxStatus: function() { return callNode("getOutboxStatus"); },
162
162
  getDiagnostics: function() { return callNode("getDiagnostics"); },
163
- getPrimaryAccount: function() { return callNode("getPrimaryAccount"); },
163
+ getPrimaryAccount: function(feature) { return callNode("getPrimaryAccount", { feature: feature }); },
164
164
  listQueuedOutgoing: function() { return callNode("listQueuedOutgoing"); },
165
165
  cancelQueuedOutgoing: function(p) { return callNode("cancelQueuedOutgoing", { path: p }); },
166
166
  reauthenticate: function(accountId) { return callNode("reauthenticate", { accountId: accountId }); },
@@ -788,6 +788,24 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
788
788
  opacity: 0.5;
789
789
  }
790
790
 
791
+ /* Reply marker — messages whose In-Reply-To points at another message get a
792
+ subtle left-edge accent so threaded replies visibly distinguish from top-
793
+ level posts without needing thread-grouping mode on. Works regardless of
794
+ whether thread-grouping is enabled. */
795
+ .ml-row.is-reply::before {
796
+ content: "";
797
+ position: absolute;
798
+ left: 0;
799
+ top: 2px;
800
+ bottom: 2px;
801
+ width: 2px;
802
+ background: oklch(0.70 0.12 250); /* muted blue */
803
+ border-radius: 0 1px 1px 0;
804
+ pointer-events: none;
805
+ opacity: 0.5;
806
+ }
807
+ .ml-row { position: relative; }
808
+
791
809
  /* S1 slice C — local action (move/flag/delete) queued but server hasn't
792
810
  ACK'd. Reuses the same date-column dot as the download indicator so
793
811
  "still on client, not yet on server" sits in the same visual slot as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.377",
3
+ "version": "1.0.378",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -39,11 +39,14 @@ export declare class MailxService {
39
39
  /** Per-account health snapshot: inactivity-timeout count, conn-cap hits,
40
40
  * last failed IMAP command. Drives the diagnostics ⚠ badge in the UI. */
41
41
  getDiagnostics(): any;
42
- /** Return the account marked `primary: true` in accounts.jsonc, or the
43
- * first account if none. Used by Calendar / Tasks / Contacts to pick
44
- * which Google account's data to show. Single-flag for now;
45
- * per-feature flags + multi-calendar comingling deferred. */
46
- getPrimaryAccount(): any;
42
+ /** Return the account that supplies `feature` data (calendar / tasks /
43
+ * contacts). Resolution order:
44
+ * 1. Any account with `primary<Feature>: true` (per-feature override)
45
+ * 2. Any account with `primary: true` (catch-all default)
46
+ * 3. First account (fallback)
47
+ * Called without `feature` it returns the catch-all primary — same
48
+ * semantics as the original single-flag version for back-compat. */
49
+ getPrimaryAccount(feature?: string): any;
47
50
  /** List queued outgoing messages with parsed envelope headers so the UI
48
51
  * can render a pink-row "pending" view before IMAP APPEND succeeds. */
49
52
  listQueuedOutgoing(): any[];
@@ -102,7 +102,14 @@ export class MailxService {
102
102
  for (const cfg of cfgs) {
103
103
  const a = dbAccounts.find(d => d.id === cfg.id);
104
104
  if (a)
105
- ordered.push({ ...a, label: cfg.label, defaultSend: cfg.defaultSend || false, primary: !!cfg.primary, identityDomains: cfg.identityDomains || [] });
105
+ ordered.push({
106
+ ...a, label: cfg.label, defaultSend: cfg.defaultSend || false,
107
+ primary: !!cfg.primary,
108
+ primaryCalendar: !!cfg.primaryCalendar,
109
+ primaryTasks: !!cfg.primaryTasks,
110
+ primaryContacts: !!cfg.primaryContacts,
111
+ identityDomains: cfg.identityDomains || [],
112
+ });
106
113
  }
107
114
  // Append any DB accounts not in settings
108
115
  for (const a of dbAccounts) {
@@ -421,12 +428,21 @@ export class MailxService {
421
428
  getDiagnostics() {
422
429
  return this.imapManager.getDiagnosticsSnapshot();
423
430
  }
424
- /** Return the account marked `primary: true` in accounts.jsonc, or the
425
- * first account if none. Used by Calendar / Tasks / Contacts to pick
426
- * which Google account's data to show. Single-flag for now;
427
- * per-feature flags + multi-calendar comingling deferred. */
428
- getPrimaryAccount() {
431
+ /** Return the account that supplies `feature` data (calendar / tasks /
432
+ * contacts). Resolution order:
433
+ * 1. Any account with `primary<Feature>: true` (per-feature override)
434
+ * 2. Any account with `primary: true` (catch-all default)
435
+ * 3. First account (fallback)
436
+ * Called without `feature` it returns the catch-all primary — same
437
+ * semantics as the original single-flag version for back-compat. */
438
+ getPrimaryAccount(feature) {
429
439
  const all = this.getAccounts();
440
+ if (feature) {
441
+ const perFeatureKey = "primary" + feature.charAt(0).toUpperCase() + feature.slice(1);
442
+ const perFeature = all.find((a) => a[perFeatureKey]);
443
+ if (perFeature)
444
+ return perFeature;
445
+ }
430
446
  return all.find((a) => a.primary) || all[0] || null;
431
447
  }
432
448
  /** List queued outgoing messages with parsed envelope headers so the UI
@@ -97,7 +97,7 @@ async function dispatchAction(svc, action, p) {
97
97
  case "getDiagnostics":
98
98
  return svc.getDiagnostics();
99
99
  case "getPrimaryAccount":
100
- return svc.getPrimaryAccount();
100
+ return svc.getPrimaryAccount(p?.feature);
101
101
  case "getOutboxStatus":
102
102
  return svc.getOutboxStatus();
103
103
  case "listQueuedOutgoing":
@@ -377,6 +377,9 @@ function normalizeAccount(acct, globalName) {
377
377
  },
378
378
  enabled: acct.enabled ?? true,
379
379
  primary: acct.primary,
380
+ primaryCalendar: acct.primaryCalendar,
381
+ primaryTasks: acct.primaryTasks,
382
+ primaryContacts: acct.primaryContacts,
380
383
  defaultSend: acct.defaultSend,
381
384
  syncContacts: acct.syncContacts ?? (provider?.imap.auth === "oauth2"),
382
385
  relayDomains: acct.relayDomains,
@@ -7,6 +7,9 @@ import type { MessageEnvelope, Folder, EmailAddress, PagedResult, MessageQuery }
7
7
  export declare class MailxDB {
8
8
  private db;
9
9
  constructor(dbDir: string);
10
+ /** Fail loud + early if expected columns are missing. Cheap (PRAGMA only
11
+ * runs at startup). The user-facing message names the recovery command. */
12
+ private verifySchema;
10
13
  /** One-time: assign UUIDs to every `messages` row that's missing one.
11
14
  * Runs on every startup but the WHERE clause makes it a no-op after the
12
15
  * first pass. */
@@ -182,6 +182,34 @@ export class MailxDB {
182
182
  // this column landed. One UPDATE + an id roundtrip per row — cheap
183
183
  // at our row counts, runs once per DB upgrade.
184
184
  this.backfillUuids();
185
+ // Post-migration sanity check: verify the columns we actually read in
186
+ // SELECTs exist. If any migration silently failed (stale driver, DB
187
+ // file locked, permission error), later code would throw cryptic
188
+ // "no such column" errors buried deep in a sync run. Fail loud here
189
+ // with a clear "run mailx -rebuild" message. C32 on Linux was exactly
190
+ // this — old mailx-store that predated thread_id/uuid migrations.
191
+ this.verifySchema();
192
+ }
193
+ /** Fail loud + early if expected columns are missing. Cheap (PRAGMA only
194
+ * runs at startup). The user-facing message names the recovery command. */
195
+ verifySchema() {
196
+ const required = {
197
+ messages: ["thread_id", "provider_id", "uuid"],
198
+ };
199
+ for (const [table, cols] of Object.entries(required)) {
200
+ let actual;
201
+ try {
202
+ actual = this.db.prepare(`PRAGMA table_info(${table})`).all();
203
+ }
204
+ catch (e) {
205
+ throw new Error(`[mailx-store] schema check failed for "${table}": ${e.message}. Run 'mailx -rebuild' to rebuild the local store.`);
206
+ }
207
+ const names = new Set(actual.map(r => r.name));
208
+ const missing = cols.filter(c => !names.has(c));
209
+ if (missing.length > 0) {
210
+ throw new Error(`[mailx-store] table "${table}" is missing columns [${missing.join(", ")}] — schema migration did not complete. Run 'mailx -rebuild' to rebuild the local store.`);
211
+ }
212
+ }
185
213
  }
186
214
  /** One-time: assign UUIDs to every `messages` row that's missing one.
187
215
  * Runs on every startup but the WHERE clause makes it a no-op after the
@@ -983,6 +983,25 @@ function installBridge() {
983
983
  syncAll: async () => { await service.syncAll(); return { ok: true }; },
984
984
  syncAccount: async (accountId) => { await service.syncAccount(accountId); return { ok: true }; },
985
985
  getSyncPending: () => service.getSyncPending(),
986
+ getPrimaryAccount: (feature) => {
987
+ // Resolve primary account for a feature (calendar/tasks/contacts):
988
+ // per-feature flag → catch-all `primary` → first account.
989
+ const all = db.getAccountConfigs().map(r => {
990
+ try {
991
+ return { id: r.id, name: r.name, email: r.email, ...JSON.parse(r.configJson) };
992
+ }
993
+ catch {
994
+ return { id: r.id, name: r.name, email: r.email };
995
+ }
996
+ });
997
+ if (feature) {
998
+ const key = "primary" + feature.charAt(0).toUpperCase() + feature.slice(1);
999
+ const perFeature = all.find((a) => a[key]);
1000
+ if (perFeature)
1001
+ return perFeature;
1002
+ }
1003
+ return all.find((a) => a.primary) || all[0] || null;
1004
+ },
986
1005
  reauthenticate: async (accountId) => ({ ok: await service.reauthenticate(accountId) }),
987
1006
  markFolderRead: (_accountId, folderId) => { service.markFolderRead(folderId); return { ok: true }; },
988
1007
  createFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
@@ -29,7 +29,10 @@ export interface AccountConfig {
29
29
  password?: string;
30
30
  };
31
31
  enabled: boolean;
32
- primary?: boolean; /** Designates the account that supplies Calendar / Tasks / Contacts data. At most one per user; first one wins if multiple set. */
32
+ primary?: boolean; /** Catch-all "this is my main account" default source for Calendar / Tasks / Contacts when no per-feature override set. */
33
+ primaryCalendar?: boolean; /** Per-feature override: use this account's Google Calendar. Falls back to `primary` if unset. */
34
+ primaryTasks?: boolean; /** Per-feature override: use this account's Google Tasks. Falls back to `primary` if unset. */
35
+ primaryContacts?: boolean; /** Per-feature override: use this account's Google Contacts. Falls back to `primary` if unset. */
33
36
  defaultSend?: boolean; /** Use this account's SMTP when From doesn't match any account */
34
37
  syncContacts?: boolean; /** Sync contacts even when account is disabled (contacts-only Gmail) */
35
38
  relayDomains?: string[]; /** Domains to skip in Delivered-To chain (e.g., ["m.connectivity.xyz"]) */