@bobfrankston/mailx 1.0.435 → 1.0.437

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.
package/client/app.js CHANGED
@@ -633,21 +633,27 @@ async function openCompose(mode) {
633
633
  ? explicitDomains
634
634
  : (accountDomain ? [accountDomain] : []);
635
635
  function detectReplyFrom() {
636
- if (!msg || identityDomains.length === 0)
636
+ if (!msg)
637
+ return undefined;
638
+ // Delivered-To is set by the receiving server — it IS an identity at this
639
+ // account, by definition. Trust it unconditionally when present (after
640
+ // deliveredToPrefix stripping in the service). Fall back to To/Cc only
641
+ // when their domain matches the account's identityDomains, since To/Cc
642
+ // can be set by the sender and aren't authoritative.
643
+ if (msg.deliveredTo) {
644
+ console.log(`[compose] reply From → ${msg.deliveredTo} (Delivered-To)`);
645
+ return msg.deliveredTo;
646
+ }
647
+ if (identityDomains.length === 0)
637
648
  return undefined;
638
- // Prefer Delivered-To header (the address the server actually delivered
639
- // to, which is the alias the message arrived at). Fall back to To, then
640
- // Cc, in order. Bcc isn't visible to recipients so skipped.
641
649
  const candidates = [
642
- msg.deliveredTo,
643
650
  ...((msg.to || []).map((a) => a.address)),
644
651
  ...((msg.cc || []).map((a) => a.address)),
645
652
  ].filter(Boolean);
646
- console.log(`[compose] detectReplyFrom: deliveredTo=${msg.deliveredTo}, to=${msg.to?.map((a) => a.address)}, cc=${msg.cc?.map((a) => a.address)}, identityDomains=${identityDomains}, accountEmail=${account?.email}`);
647
653
  for (const addr of candidates) {
648
654
  const domain = addr.split("@")[1]?.toLowerCase();
649
655
  if (domain && identityDomains.some(d => domain === d || domain.endsWith(`.${d}`))) {
650
- console.log(`[compose] reply From → ${addr}`);
656
+ console.log(`[compose] reply From → ${addr} (To/Cc match)`);
651
657
  return addr;
652
658
  }
653
659
  }
@@ -2219,7 +2225,7 @@ document.getElementById("btn-open-log")?.addEventListener("click", async () => {
2219
2225
  }
2220
2226
  });
2221
2227
  async function openJsoncEditor(initialFile) {
2222
- const { readJsoncFile, writeJsoncFile, readConfigHelp } = await import("./lib/api-client.js");
2228
+ const { readJsoncFile, writeJsoncFile, readConfigHelp, formatJsonc } = await import("./lib/api-client.js");
2223
2229
  const backdrop = document.createElement("div");
2224
2230
  backdrop.className = "mailx-modal-backdrop";
2225
2231
  const panel = document.createElement("div");
@@ -2232,6 +2238,7 @@ async function openJsoncEditor(initialFile) {
2232
2238
  <label class="mailx-modal-label">File
2233
2239
  <select class="mailx-modal-input" id="jsonc-file">
2234
2240
  <option value="accounts.jsonc">accounts.jsonc — accounts (shared via Google Drive)</option>
2241
+ <option value="contacts.jsonc">contacts.jsonc — preferred + denylist + discovered (shared)</option>
2235
2242
  <option value="allowlist.jsonc">allowlist.jsonc — remote-content allowlist (shared)</option>
2236
2243
  <option value="clients.jsonc">clients.jsonc — per-device registrations (shared)</option>
2237
2244
  <option value="config.jsonc">config.jsonc — local per-machine overrides (not synced)</option>
@@ -2253,6 +2260,7 @@ async function openJsoncEditor(initialFile) {
2253
2260
  </div>
2254
2261
  <div class="mailx-modal-error" id="jsonc-error" hidden></div>
2255
2262
  <div class="mailx-modal-buttons">
2263
+ <button type="button" class="mailx-modal-btn" data-action="format" title="Reformat indentation while preserving comments and trailing commas">Format</button>
2256
2264
  <span class="mailx-modal-spacer"></span>
2257
2265
  <button type="button" class="mailx-modal-btn" data-action="cancel">Cancel</button>
2258
2266
  <button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="save">Save</button>
@@ -2311,17 +2319,34 @@ async function openJsoncEditor(initialFile) {
2311
2319
  renderGutter();
2312
2320
  };
2313
2321
  const showValidation = (err) => {
2314
- errorEl.textContent = `Line ${err.line}, col ${err.col}: ${err.message}`;
2322
+ // CRITICAL: do NOT move the cursor here. Validation fires every 600ms
2323
+ // while the user types; auto-selecting the error position yanked the
2324
+ // cursor mid-edit and made fixing the error impossible (the user
2325
+ // reported this as a fatal bug — the very mechanism preventing a save
2326
+ // was preventing the fix). Location is shown via the gutter highlight
2327
+ // + the "Line N, col M" message, and the user can click "Jump" to
2328
+ // explicitly navigate.
2329
+ errorEl.innerHTML = "";
2330
+ const text = document.createElement("span");
2331
+ text.textContent = `Line ${err.line}, col ${err.col}: ${err.message} `;
2332
+ const jumpBtn = document.createElement("button");
2333
+ jumpBtn.type = "button";
2334
+ jumpBtn.className = "mailx-modal-btn mailx-modal-btn-link";
2335
+ jumpBtn.textContent = "Jump to error";
2336
+ jumpBtn.addEventListener("click", () => {
2337
+ textarea.focus();
2338
+ try {
2339
+ textarea.setSelectionRange(err.pos, err.pos + 1);
2340
+ }
2341
+ catch { /* */ }
2342
+ });
2343
+ errorEl.appendChild(text);
2344
+ errorEl.appendChild(jumpBtn);
2315
2345
  errorEl.hidden = false;
2316
2346
  textarea.classList.add("mailx-modal-input-error");
2317
2347
  saveBtn.disabled = true;
2318
2348
  errorLine = err.line;
2319
2349
  renderGutter();
2320
- // Select the problem character so the browser draws a visible marker
2321
- try {
2322
- textarea.setSelectionRange(err.pos, err.pos + 1);
2323
- }
2324
- catch { /* out-of-range → ignore */ }
2325
2350
  };
2326
2351
  let validateTimer;
2327
2352
  const scheduleValidate = () => {
@@ -2377,6 +2402,31 @@ async function openJsoncEditor(initialFile) {
2377
2402
  close();
2378
2403
  return;
2379
2404
  }
2405
+ if (action === "format") {
2406
+ // Reformat via the service-side jsonc-parser format() — the
2407
+ // edits are whitespace-only, so `//` and `/* */` comments
2408
+ // survive intact (which JSON.stringify(parse(...)) does not).
2409
+ btn.disabled = true;
2410
+ const orig = btn.textContent;
2411
+ btn.textContent = "Formatting…";
2412
+ try {
2413
+ const r = await formatJsonc(textarea.value);
2414
+ if (r?.content !== undefined) {
2415
+ textarea.value = r.content;
2416
+ renderGutter();
2417
+ scheduleValidate();
2418
+ }
2419
+ }
2420
+ catch (e) {
2421
+ errorEl.textContent = `Format failed: ${e.message}`;
2422
+ errorEl.hidden = false;
2423
+ }
2424
+ finally {
2425
+ btn.disabled = false;
2426
+ btn.textContent = orig || "Format";
2427
+ }
2428
+ return;
2429
+ }
2380
2430
  if (action === "save") {
2381
2431
  // Final sync-check; refuse to save if it doesn't parse
2382
2432
  const err = validateJsonc(textarea.value);
@@ -2,7 +2,7 @@
2
2
  * Message viewer component -- displays full message in sandboxed iframe.
3
3
  * Subscribes to message-state: clears when selected becomes null.
4
4
  */
5
- import { getMessage, updateFlags, allowRemoteContent, getAttachment, addContact, listContacts, upsertContact } from "../lib/api-client.js";
5
+ import { getMessage, updateFlags, allowRemoteContent, getAttachment, addContact, listContacts, upsertContact, unsubscribeOneClick } from "../lib/api-client.js";
6
6
  import { showContextMenu } from "./context-menu.js";
7
7
  import * as state from "../lib/message-state.js";
8
8
  /** Currently displayed message (for reply/forward) */
@@ -358,17 +358,13 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
358
358
  }
359
359
  headerEl.querySelector(".mv-date").textContent = new Date(msg.date).toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
360
360
  // Unsubscribe button (upper right of header).
361
- // - HTTPS URL: open externally (same path as right-click open link
362
- // in a new window). Earlier code attempted RFC 8058 one-click POST
363
- // first, but the IPC path was failing silently the button "moved
364
- // a tad" (CSS :active) but no visible feedback. User preference:
365
- // just open the link, the way the working right-click does.
366
- // - mailto: URL: open a pre-filled compose window so the reply gets
367
- // sent from the correct mailx account, not the OS default mail
368
- // handler.
361
+ // - One-Click (RFC 8058): POST via service; show result in status bar.
362
+ // - Plain HTTPS URL: open externally for user confirmation.
363
+ // - mailto: open a pre-filled compose so the reply uses the right account.
369
364
  const unsubBtn = document.getElementById("mv-unsubscribe");
370
365
  const httpUrl = msg.listUnsubscribeHttp || "";
371
366
  const mailUrl = msg.listUnsubscribeMail || "";
367
+ const oneClick = !!msg.listUnsubscribeOneClick;
372
368
  const anyUrl = httpUrl || mailUrl || msg.listUnsubscribe || "";
373
369
  if (unsubBtn) {
374
370
  if (anyUrl) {
@@ -376,8 +372,26 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
376
372
  unsubBtn.textContent = "Unsubscribe";
377
373
  unsubBtn.removeAttribute("title");
378
374
  unsubBtn.href = httpUrl || mailUrl || "#";
379
- unsubBtn.onclick = (e) => {
375
+ unsubBtn.onclick = async (e) => {
380
376
  e.preventDefault();
377
+ const status = document.getElementById("status-sync");
378
+ if (httpUrl && oneClick) {
379
+ if (status)
380
+ status.textContent = "Unsubscribing…";
381
+ try {
382
+ const result = await unsubscribeOneClick(httpUrl);
383
+ if (status) {
384
+ status.textContent = result.ok
385
+ ? `Unsubscribed (${result.status} ${result.statusText})`
386
+ : `Unsubscribe failed: ${result.status} ${result.statusText}`;
387
+ }
388
+ }
389
+ catch (err) {
390
+ if (status)
391
+ status.textContent = `Unsubscribe error: ${err?.message || err}`;
392
+ }
393
+ return;
394
+ }
381
395
  if (httpUrl) {
382
396
  const api = window.mailxapi;
383
397
  if (api?.openExternal)
@@ -339,6 +339,9 @@ export function readJsoncFile(name) {
339
339
  export function writeJsoncFile(name, content) {
340
340
  return ipc().writeJsoncFile?.(name, content);
341
341
  }
342
+ export function formatJsonc(content) {
343
+ return ipc().formatJsonc?.(content);
344
+ }
342
345
  export function readConfigHelp(name) {
343
346
  return ipc().readConfigHelp?.(name) ?? Promise.resolve({ content: "" });
344
347
  }
@@ -131,6 +131,9 @@
131
131
  writeJsoncFile: function(name, content) {
132
132
  return callNode("writeJsoncFile", { name: name, content: content });
133
133
  },
134
+ formatJsonc: function(content) {
135
+ return callNode("formatJsonc", { content: content });
136
+ },
134
137
  readConfigHelp: function(name) {
135
138
  return callNode("readConfigHelp", { name: name });
136
139
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.435",
3
+ "version": "1.0.437",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -252,6 +252,7 @@ export declare class MailxService {
252
252
  * Names are whitelisted so the UI can't read arbitrary files.
253
253
  * `config.jsonc` is the local per-machine config (not cloud-synced). */
254
254
  readJsoncFile(name: string): Promise<string | null>;
255
+ formatJsonc(content: string): Promise<string>;
255
256
  /** Return the help section for a named config file, extracted from docs/config-help.md.
256
257
  * Matches a level-2 heading whose text equals the filename. Returns markdown. */
257
258
  readConfigHelp(name: string): Promise<string>;
@@ -1762,6 +1762,17 @@ export class MailxService {
1762
1762
  const { cloudRead } = await import("@bobfrankston/mailx-settings");
1763
1763
  return cloudRead(name);
1764
1764
  }
1765
+ // Reformat JSONC preserving comments — applyEdits returns whitespace-only edits.
1766
+ async formatJsonc(content) {
1767
+ const { format, applyEdits } = await import("jsonc-parser");
1768
+ const edits = format(content, undefined, {
1769
+ tabSize: 2,
1770
+ insertSpaces: true,
1771
+ eol: "\n",
1772
+ insertFinalNewline: true,
1773
+ });
1774
+ return applyEdits(content, edits);
1775
+ }
1765
1776
  /** Return the help section for a named config file, extracted from docs/config-help.md.
1766
1777
  * Matches a level-2 heading whose text equals the filename. Returns markdown. */
1767
1778
  async readConfigHelp(name) {
@@ -169,6 +169,8 @@ async function dispatchAction(svc, action, p) {
169
169
  case "writeJsoncFile":
170
170
  await svc.writeJsoncFile(p.name, p.content);
171
171
  return { ok: true };
172
+ case "formatJsonc":
173
+ return { content: await svc.formatJsonc(p.content) };
172
174
  case "readConfigHelp":
173
175
  return { content: await svc.readConfigHelp(p.name) };
174
176
  case "unsubscribeOneClick":
@@ -287,6 +287,10 @@ export class MailxDB {
287
287
  this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_uuid ON messages(uuid)");
288
288
  }
289
289
  catch { /* already exists */ }
290
+ // bcc_json: pre-existing DBs predate this column. Without the migration
291
+ // every contacts seed pass throws "no such column: m.bcc_json" and the
292
+ // local autocomplete corpus stays empty.
293
+ this.addColumnIfMissing("messages", "bcc_json", "TEXT DEFAULT '[]'");
290
294
  // calendar_events: recurring_event_id carries the Google Calendar
291
295
  // series id when the event is an expanded instance of a recurrence.
292
296
  // Filters like "hide recurring events" check this column.
@@ -343,7 +347,7 @@ export class MailxDB {
343
347
  * runs at startup). The user-facing message names the recovery command. */
344
348
  verifySchema() {
345
349
  const required = {
346
- messages: ["thread_id", "provider_id", "uuid"],
350
+ messages: ["thread_id", "provider_id", "uuid", "bcc_json"],
347
351
  calendar_events: ["recurring_event_id", "html_link"],
348
352
  };
349
353
  for (const [table, cols] of Object.entries(required)) {
@@ -1434,10 +1438,21 @@ export class MailxDB {
1434
1438
  query = (query || "").trim();
1435
1439
  if (!query)
1436
1440
  return [];
1437
- const substr = `%${query}%`;
1441
+ // Split into whitespace-separated tokens. Each token must appear in
1442
+ // name or email — order- and adjacency-independent. So "eleanor elkin"
1443
+ // matches "Eleanor Elkin", "Elkin, Eleanor", "Eleanor M Elkin", and
1444
+ // "elkin@eleanor.example". The first token gets the prefix bonus for
1445
+ // ranking; remaining tokens just have to be present.
1446
+ const tokens = query.split(/\s+/).filter(Boolean);
1447
+ const firstSubstr = `%${tokens[0]}%`;
1448
+ const firstPrefix = `${tokens[0]}%`;
1449
+ const tokenWhere = tokens.map(() => "(name LIKE ? OR email LIKE ?)").join(" AND ");
1450
+ const tokenParams = [];
1451
+ for (const t of tokens) {
1452
+ tokenParams.push(`%${t}%`, `%${t}%`);
1453
+ }
1438
1454
  let rows;
1439
1455
  try {
1440
- const prefixQ = `${query}%`;
1441
1456
  // Source tier: anything not in the two reserved system sources
1442
1457
  // ('google', 'discovered') is preferred-tier — i.e. came out of
1443
1458
  // contacts.jsonc#preferred[]. The user's `source: "work"` /
@@ -1456,17 +1471,17 @@ export class MailxDB {
1456
1471
  ELSE 40
1457
1472
  END) AS match_rank
1458
1473
  FROM contacts
1459
- WHERE email LIKE ? OR name LIKE ?
1474
+ WHERE ${tokenWhere}
1460
1475
  ORDER BY match_rank DESC, use_count DESC, last_used DESC
1461
- LIMIT ?`).all(prefixQ, prefixQ, substr, substr, substr, substr, limit * 2);
1476
+ LIMIT ?`).all(firstPrefix, firstPrefix, firstSubstr, firstSubstr, ...tokenParams, limit * 2);
1462
1477
  }
1463
1478
  catch (e) {
1464
1479
  console.error(` [searchContacts] ranked query failed (${e?.message}) — falling back to simple LIKE`);
1465
1480
  rows = this.db.prepare(`SELECT name, email, source, use_count, last_used, 0 AS match_rank
1466
1481
  FROM contacts
1467
- WHERE email LIKE ? OR name LIKE ?
1482
+ WHERE ${tokenWhere}
1468
1483
  ORDER BY use_count DESC, last_used DESC
1469
- LIMIT ?`).all(substr, substr, limit * 2);
1484
+ LIMIT ?`).all(...tokenParams, limit * 2);
1470
1485
  }
1471
1486
  // Filter out denylisted emails as a defense-in-depth — applyContactsConfig
1472
1487
  // already purges discovered rows on denylist, but a Google sync that
package/unwedge.cmd CHANGED
@@ -1 +1 @@
1
- rmdir C:\Users\Bob\.claude\session-env\7299facd-d726-4f8a-8e7a-dbd852680c95 /s /q
1
+ rmdir C:\Users\Bob\.claude\session-env\6787e337-1af0-423c-ae58-8d981702aebb /s /q