@bobfrankston/mailx 1.0.436 → 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
  }
@@ -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>
@@ -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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.436",
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",
@@ -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