@bobfrankston/mailx 1.0.428 → 1.0.432

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/README.md CHANGED
@@ -15,6 +15,8 @@ mailx
15
15
 
16
16
  Requires Node.js 22 or later.
17
17
 
18
+ The package installs two equivalent commands: **`mailx`** and **`bobmail`**. They're the same binary — `bobmail` is provided as an alternative for environments where `mailx` collides with another tool (most commonly the BSD `mailx`/Heirloom mailx at `/usr/bin/mailx` on macOS and some Linux distributions). Use whichever name doesn't conflict on your system.
19
+
18
20
  On Windows, the native WebView2 app launches automatically via IPC (no HTTP server needed). Use `mailx --server` for browser mode at `http://127.0.0.1:9333`.
19
21
 
20
22
  ## First-Time Setup
package/client/app.js CHANGED
@@ -492,16 +492,40 @@ document.getElementById("btn-restart-quick")?.addEventListener("click", async ()
492
492
  if (restartDropdown)
493
493
  restartDropdown.hidden = true;
494
494
  if (isApp) {
495
- // Android: check for updates before reloading
495
+ // Android has no daemon only the WebView. Reload-the-page is the
496
+ // right action there. Desktop IPC mode is a different story below.
496
497
  if (window.mailxapi?.platform === "android") {
497
498
  const f = document.createElement("iframe");
498
499
  f.style.display = "none";
499
500
  f.src = "mailxapi://checkUpdate";
500
501
  document.body.appendChild(f);
501
502
  setTimeout(() => f.remove(), 100);
503
+ location.reload();
504
+ return;
505
+ }
506
+ // Desktop IPC mode: there IS a daemon (the --daemon child of mailx)
507
+ // running mailx-service / mailx-imap / mailx-store. Just calling
508
+ // location.reload() reloads the WebView but the daemon keeps running
509
+ // the old code, so daemon-side changes (sync, store, IPC handlers)
510
+ // don't get picked up. Trigger restartDaemon — it spawns a fresh
511
+ // `mailx` process, hands off the instance.json slot, then gracefully
512
+ // shuts down the current daemon. The UI reloads after a short delay
513
+ // so the new daemon's WebView replaces this one.
514
+ const statusSync = document.getElementById("status-sync");
515
+ if (statusSync)
516
+ statusSync.textContent = "Restarting...";
517
+ const ipc = window.mailxapi;
518
+ if (ipc?.restartDaemon) {
519
+ try {
520
+ await ipc.restartDaemon();
521
+ }
522
+ catch { /* daemon shutting down */ }
523
+ setTimeout(() => location.reload(), 2000);
524
+ }
525
+ else {
526
+ // Older host with no restartDaemon IPC — fall back to UI reload.
527
+ location.reload();
502
528
  }
503
- // IPC mode: reload the UI (no server to restart)
504
- location.reload();
505
529
  }
506
530
  else {
507
531
  const statusSync = document.getElementById("status-sync");
@@ -449,15 +449,30 @@ function applyInit(init) {
449
449
  }
450
450
  }
451
451
  // C42: append the account's signature (if configured) BEFORE rendering
452
- // the body. For new mode: just signature. For reply/forward: appended
453
- // after the quoted block. Drafts are skippedthe signature is already
454
- // baked into the saved body. Editing existing draft also skipped.
452
+ // the body. Two sources, in priority order:
453
+ // 1. New `sig: { text, html? }` objectapplied to NEW messages only.
454
+ // `text` is HTML-escaped (newlines <br>) unless `html: true` is set
455
+ // (reserved for future use; currently `html` is ignored and text is
456
+ // always escaped).
457
+ // 2. Legacy `signature: string` — HTML, applied to new + reply + forward.
458
+ //
459
+ // Drafts are skipped — the signature is already baked into the saved body.
460
+ // Editing an existing draft also skipped.
455
461
  let bodyToRender = init.bodyHtml || "";
456
462
  const acct = init.accounts.find(a => a.id === init.accountId);
457
- const sig = acct?.signature || "";
458
- if (sig && init.mode !== "draft" && !init.draftUid) {
459
- const sigBlock = `<br><br>--<br>${sig}`;
460
- bodyToRender = init.mode === "reply" || init.mode === "replyAll" || init.mode === "forward"
463
+ const isNew = init.mode !== "reply" && init.mode !== "replyAll"
464
+ && init.mode !== "forward" && init.mode !== "draft" && !init.draftUid;
465
+ const isReplyForward = init.mode === "reply" || init.mode === "replyAll"
466
+ || init.mode === "forward";
467
+ if (isNew && acct?.sig?.text) {
468
+ const sigText = acct.sig.html
469
+ ? acct.sig.text // future: trust as raw HTML
470
+ : escapeHtml(acct.sig.text).replace(/\n/g, "<br>");
471
+ bodyToRender = `${bodyToRender}<br><br>-- <br>${sigText}`;
472
+ }
473
+ else if (acct?.signature && init.mode !== "draft" && !init.draftUid) {
474
+ const sigBlock = `<br><br>--<br>${acct.signature}`;
475
+ bodyToRender = isReplyForward
461
476
  ? `<br>${sigBlock}<br>${bodyToRender}` // sig above the quote
462
477
  : `${bodyToRender}${sigBlock}`; // sig at the end for new
463
478
  }
@@ -959,7 +974,13 @@ function formatSize(n) {
959
974
  return `${(n / 1024).toFixed(1)} KB`;
960
975
  return `${(n / (1024 * 1024)).toFixed(1)} MB`;
961
976
  }
977
+ /** Set when the user clicks Attach — the native file picker eats the Esc
978
+ * press, but on Windows WebView2 the keydown can still spill to the page
979
+ * and trip the document-level Esc-closes-compose handler. While this flag
980
+ * is set, that handler short-circuits. Cleared shortly after click. */
981
+ let attachJustClicked = 0;
962
982
  document.getElementById("btn-attach")?.addEventListener("click", () => {
983
+ attachJustClicked = Date.now();
963
984
  fileInput?.click();
964
985
  });
965
986
  async function ingestFiles(files) {
@@ -1050,6 +1071,12 @@ document.addEventListener("keydown", (e) => {
1050
1071
  document.getElementById("btn-send")?.click();
1051
1072
  }
1052
1073
  if (e.key === "Escape") {
1074
+ // If the user just clicked Attach, the native file picker is up.
1075
+ // The picker swallows the Esc that dismissed it, but the keydown can
1076
+ // still bubble here on WebView2 — closing the whole compose. Suppress
1077
+ // for a short window after the attach click.
1078
+ if (Date.now() - attachJustClicked < 1500)
1079
+ return;
1053
1080
  e.preventDefault();
1054
1081
  handleCloseRequest();
1055
1082
  }
package/client/index.html CHANGED
@@ -70,7 +70,7 @@
70
70
  <span class="tb-icon">⚡</span><span class="tb-label"> Restart ▾</span>
71
71
  </button>
72
72
  <div class="tb-menu-dropdown" id="restart-dropdown" hidden>
73
- <button class="tb-menu-item" id="btn-restart-quick" title="Reload the page">Reload</button>
73
+ <button class="tb-menu-item" id="btn-restart-quick" title="Restart the mailx daemon and reload the UI — picks up new code after a build">Restart</button>
74
74
  <button class="tb-menu-item" id="btn-update" title="Check for updates and install if available">Check for updates</button>
75
75
  <button class="tb-menu-item" id="btn-rebuild" title="Wipe local DB and message cache, re-download everything. Accounts and settings are preserved. Safe and fast.">Rebuild local cache</button>
76
76
  <hr class="tb-menu-sep">
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-client",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.428",
3
+ "version": "1.0.432",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -36,7 +36,7 @@
36
36
  "@bobfrankston/iflow-node": "^0.1.8",
37
37
  "@bobfrankston/miscinfo": "^1.0.10",
38
38
  "@bobfrankston/oauthsupport": "^1.0.25",
39
- "@bobfrankston/msger": "^0.1.361",
39
+ "@bobfrankston/msger": "^0.1.362",
40
40
  "@bobfrankston/mailx-host": "^0.1.8",
41
41
  "@capacitor/android": "^8.3.0",
42
42
  "@capacitor/cli": "^8.3.0",
@@ -100,7 +100,7 @@
100
100
  "@bobfrankston/iflow-node": "^0.1.8",
101
101
  "@bobfrankston/miscinfo": "^1.0.10",
102
102
  "@bobfrankston/oauthsupport": "^1.0.25",
103
- "@bobfrankston/msger": "^0.1.361",
103
+ "@bobfrankston/msger": "^0.1.362",
104
104
  "@bobfrankston/mailx-host": "^0.1.8",
105
105
  "@capacitor/android": "^8.3.0",
106
106
  "@capacitor/cli": "^8.3.0",
@@ -395,6 +395,8 @@ function normalizeAccount(acct, globalName) {
395
395
  // blocking the build. Once mailx-types has rebuilt once (post-field)
396
396
  // this cast can be removed.
397
397
  ...(acct.signature ? { signature: acct.signature } : {}),
398
+ ...(acct.sig && typeof acct.sig === "object" && typeof acct.sig.text === "string"
399
+ ? { sig: { text: acct.sig.text, html: !!acct.sig.html } } : {}),
398
400
  };
399
401
  }
400
402
  // ── Defaults ──
@@ -196,7 +196,18 @@ export declare class MailxDB {
196
196
  rollbackTransaction(): void;
197
197
  /** Record an address used in sent mail */
198
198
  recordSentAddress(name: string, email: string): void;
199
- /** Seed contacts from all message senders in the DB */
199
+ /** Seed contacts from every address that appears in any cached message,
200
+ * with two distinctions the prior version missed:
201
+ * 1. Sent-folder messages: harvest only To/Cc/Bcc (the user's *own*
202
+ * From-address gets skipped — no point pre-filling our own
203
+ * address into autocomplete). These are tagged `source='sent'`.
204
+ * 2. Other-folder messages: harvest From + To + Cc + Bcc, tagged
205
+ * `source='received'`. Lower priority than 'sent' or 'google'
206
+ * in searchContacts ranking.
207
+ * Sent wins on insert conflict, so a person you've corresponded with
208
+ * ends up tagged 'sent' (more authoritative). Junk addresses (noreply,
209
+ * mailer-daemon, postmaster, *-bounces) are dropped at seed time so
210
+ * they never even enter autocomplete. */
200
211
  seedContactsFromMessages(): number;
201
212
  /** Search contacts by name or email prefix */
202
213
  searchContacts(query: string, limit?: number): {
@@ -7,6 +7,27 @@ import { DatabaseSync } from "node:sqlite";
7
7
  import { randomUUID } from "node:crypto";
8
8
  import * as path from "node:path";
9
9
  import * as fs from "node:fs";
10
+ /** Addresses that have no business in autocomplete. Standard automated
11
+ * sender patterns plus name-side hints ("MAILER-DAEMON" etc.). The exact
12
+ * match list keeps surprise low; the regex catches the long tail of
13
+ * *-bounces@, no-reply variants, and listserv-style addresses. */
14
+ const JUNK_LOCAL_RE = /^(no-?reply|do-?not-?reply|noreply|mailer-daemon|postmaster|abuse|automated|bounce(s|d)?|list-?(server|admin|owner|manager)?|notification|notifications?|admin@.*automated|root|daemon|nobody|undisclosed)$/i;
15
+ const JUNK_LOCAL_SUFFIX_RE = /(-bounces|\+bounces|-noreply|-no-reply|-notifications?|-mailer)$/i;
16
+ function isJunkContact(email, name) {
17
+ const local = email.split("@")[0] || "";
18
+ if (JUNK_LOCAL_RE.test(local))
19
+ return true;
20
+ if (JUNK_LOCAL_SUFFIX_RE.test(local))
21
+ return true;
22
+ // Bare numeric / hex addresses (rotating IDs from automated systems)
23
+ // — three or fewer chars is too short to be useful regardless.
24
+ if (local.length < 2)
25
+ return true;
26
+ const lname = (name || "").trim().toLowerCase();
27
+ if (lname.includes("mailer-daemon") || lname.includes("postmaster"))
28
+ return true;
29
+ return false;
30
+ }
10
31
  const SCHEMA = `
11
32
  CREATE TABLE IF NOT EXISTS accounts (
12
33
  id TEXT PRIMARY KEY,
@@ -1098,23 +1119,130 @@ export class MailxDB {
1098
1119
  this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('sent', ?, ?, ?, 1, ?)").run(name, email, now, now);
1099
1120
  }
1100
1121
  }
1101
- /** Seed contacts from all message senders in the DB */
1122
+ /** Seed contacts from every address that appears in any cached message,
1123
+ * with two distinctions the prior version missed:
1124
+ * 1. Sent-folder messages: harvest only To/Cc/Bcc (the user's *own*
1125
+ * From-address gets skipped — no point pre-filling our own
1126
+ * address into autocomplete). These are tagged `source='sent'`.
1127
+ * 2. Other-folder messages: harvest From + To + Cc + Bcc, tagged
1128
+ * `source='received'`. Lower priority than 'sent' or 'google'
1129
+ * in searchContacts ranking.
1130
+ * Sent wins on insert conflict, so a person you've corresponded with
1131
+ * ends up tagged 'sent' (more authoritative). Junk addresses (noreply,
1132
+ * mailer-daemon, postmaster, *-bounces) are dropped at seed time so
1133
+ * they never even enter autocomplete. */
1102
1134
  seedContactsFromMessages() {
1135
+ const VALID = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
1103
1136
  const now = Date.now();
1104
- const rows = this.db.prepare(`SELECT from_name, from_address, COUNT(*) as cnt, MAX(date) as last
1105
- FROM messages
1106
- WHERE from_address != ''
1107
- GROUP BY from_address`).all();
1137
+ // Aggregator keyed by lowercased email. `tag` is 'sent' or 'received'
1138
+ // — sent wins on conflict because it implies the user wrote to that
1139
+ // address (more authoritative than passive observation).
1140
+ const agg = new Map();
1141
+ const bump = (name, address, date, tag) => {
1142
+ const email = (address || "").trim().toLowerCase();
1143
+ if (!email || !VALID.test(email))
1144
+ return;
1145
+ if (isJunkContact(email, name))
1146
+ return;
1147
+ const e = agg.get(email);
1148
+ if (e) {
1149
+ e.cnt++;
1150
+ if (date > e.last)
1151
+ e.last = date;
1152
+ if (!e.name && name)
1153
+ e.name = name;
1154
+ if (tag === "sent")
1155
+ e.tag = "sent"; // upgrade
1156
+ }
1157
+ else {
1158
+ agg.set(email, { name: name || "", cnt: 1, last: date || 0, tag });
1159
+ }
1160
+ };
1161
+ // Sent folder rows: skip the From (it's us), harvest the recipients.
1162
+ const sentRows = this.db.prepare(`SELECT m.to_json, m.cc_json, m.bcc_json, m.date
1163
+ FROM messages m
1164
+ JOIN folders f ON m.folder_id = f.id
1165
+ WHERE f.special_use = 'sent'`).all();
1166
+ for (const r of sentRows) {
1167
+ const date = r.date || 0;
1168
+ for (const field of [r.to_json, r.cc_json, r.bcc_json]) {
1169
+ if (!field)
1170
+ continue;
1171
+ let parsed;
1172
+ try {
1173
+ parsed = JSON.parse(field);
1174
+ }
1175
+ catch {
1176
+ continue;
1177
+ }
1178
+ if (!Array.isArray(parsed))
1179
+ continue;
1180
+ for (const a of parsed) {
1181
+ if (!a)
1182
+ continue;
1183
+ bump(a.name || "", a.address || a.email || "", date, "sent");
1184
+ }
1185
+ }
1186
+ }
1187
+ // Other folders: harvest everything.
1188
+ const recvRows = this.db.prepare(`SELECT m.from_name, m.from_address, m.to_json, m.cc_json, m.bcc_json, m.date
1189
+ FROM messages m
1190
+ LEFT JOIN folders f ON m.folder_id = f.id
1191
+ WHERE f.special_use IS NULL OR f.special_use != 'sent'`).all();
1192
+ for (const r of recvRows) {
1193
+ const date = r.date || 0;
1194
+ bump(r.from_name, r.from_address, date, "received");
1195
+ for (const field of [r.to_json, r.cc_json, r.bcc_json]) {
1196
+ if (!field)
1197
+ continue;
1198
+ let parsed;
1199
+ try {
1200
+ parsed = JSON.parse(field);
1201
+ }
1202
+ catch {
1203
+ continue;
1204
+ }
1205
+ if (!Array.isArray(parsed))
1206
+ continue;
1207
+ for (const a of parsed) {
1208
+ if (!a)
1209
+ continue;
1210
+ bump(a.name || "", a.address || a.email || "", date, "received");
1211
+ }
1212
+ }
1213
+ }
1108
1214
  let added = 0;
1109
- for (const r of rows) {
1110
- // Skip invalid addresses so contact autocomplete never proposes non-emails
1111
- if (!r.from_address || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(r.from_address))
1112
- continue;
1113
- const existing = this.db.prepare("SELECT id FROM contacts WHERE email = ?").get(r.from_address);
1215
+ let bumped = 0;
1216
+ let upgraded = 0;
1217
+ const insStmt = this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES (?, ?, ?, ?, ?, ?)");
1218
+ // Refresh seed-derived rows ('sent' or 'received') with fresh counters.
1219
+ // Allow upgrading a 'received' row to 'sent' if the seed now sees it
1220
+ // in the Sent folder. 'google' rows stay untouched — they're owned
1221
+ // by syncGoogleContacts and carry curated names/orgs.
1222
+ const updStmt = this.db.prepare(`UPDATE contacts SET source = ?, use_count = ?,
1223
+ last_used = max(last_used, ?),
1224
+ name = CASE WHEN name = '' AND ? != '' THEN ? ELSE name END,
1225
+ updated_at = ?
1226
+ WHERE email = ? AND source IN ('sent', 'received')`);
1227
+ for (const [email, info] of agg) {
1228
+ const existing = this.db.prepare("SELECT id, source FROM contacts WHERE email = ?").get(email);
1114
1229
  if (!existing) {
1115
- this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('received', ?, ?, ?, ?, ?)").run(r.from_name || "", r.from_address, r.last, r.cnt, now);
1230
+ insStmt.run(info.tag, info.name, email, info.last, info.cnt, now);
1116
1231
  added++;
1117
1232
  }
1233
+ else if (existing.source === "google") {
1234
+ // Don't overwrite curated address-book rows; recordSentAddress
1235
+ // already bumps their use_count when actual sends happen.
1236
+ }
1237
+ else {
1238
+ if (existing.source !== info.tag && info.tag === "sent")
1239
+ upgraded++;
1240
+ updStmt.run(info.tag, info.cnt, info.last, info.name, info.name, now, email);
1241
+ bumped++;
1242
+ }
1243
+ }
1244
+ if (added > 0 || upgraded > 0) {
1245
+ console.log(` [contacts] seed: ${added} new + ${bumped} refreshed (${upgraded} upgraded received→sent)`);
1118
1246
  }
1119
1247
  return added;
1120
1248
  }
@@ -1135,12 +1263,29 @@ export class MailxDB {
1135
1263
  let rows;
1136
1264
  try {
1137
1265
  const prefixQ = `${query}%`;
1266
+ // match_rank combines two signals: how the query matches the
1267
+ // contact (name prefix > local-part prefix > substring), and
1268
+ // where the contact came from (curated Google address book >
1269
+ // someone you've sent to > someone who emailed you). Without
1270
+ // the source bonus, the seeded "received" rows — newsletters,
1271
+ // mailing lists, no-reply senders — drown out the People API
1272
+ // contacts because they have higher use_count from the corpus
1273
+ // aggregation. The +30 / +20 / 0 spread is large enough that a
1274
+ // google-source row with weak query match still beats a
1275
+ // received-source row with the same query match, but small
1276
+ // enough that within a tier the recency-weighted use_count
1277
+ // (computed in JS below, magnitude ~10000×) still differentiates.
1138
1278
  rows = this.db.prepare(`SELECT name, email, source, use_count, last_used,
1139
1279
  (CASE
1140
1280
  WHEN lower(name) LIKE lower(?) THEN 3
1141
1281
  WHEN substr(email, 1, instr(email, '@') - 1) LIKE lower(?) THEN 2
1142
1282
  WHEN email LIKE ? OR name LIKE ? THEN 1
1143
1283
  ELSE 0
1284
+ END) +
1285
+ (CASE source
1286
+ WHEN 'google' THEN 30
1287
+ WHEN 'sent' THEN 20
1288
+ ELSE 0
1144
1289
  END) AS match_rank
1145
1290
  FROM contacts
1146
1291
  WHERE email LIKE ? OR name LIKE ?
@@ -39,7 +39,14 @@ export interface AccountConfig {
39
39
  deliveredToPrefix?: string[]; /** Prefixes to strip from Delivered-To to get clean alias (e.g., ["bobf-ma-", "bobf-"]) — order matters, longest first */
40
40
  identityDomains?: string[]; /** Domains where Delivered-To address should become the reply From (e.g., ["bob.ma", "bobf.frankston.com"]) */
41
41
  spam?: string; /** IMAP folder path for "Mark as spam" button (e.g., "_spam"). Button hidden when not set. */
42
- signature?: string; /** HTML signature appended to outgoing messages from this account. Plain text or HTML allowed. */
42
+ signature?: string; /** Legacy: HTML signature appended to all outgoing messages (new + reply + forward). Plain text or HTML allowed. Superseded by `sig`. */
43
+ sig?: AccountSignature; /** Per-account signature object. Initially appended only to NEW messages; later options will cover replies/forwards. */
44
+ }
45
+ /** Signature configuration in accounts.jsonc. Initial shape carries `text`
46
+ * only; `html: true` reserved for future support of raw HTML signatures. */
47
+ export interface AccountSignature {
48
+ text: string; /** Plain-text signature body. Newlines preserved. Appended to NEW messages with the standard "-- " RFC 3676 separator. */
49
+ html?: boolean; /** Future flag: when true, `text` is treated as raw HTML rather than escaped plain text. Currently ignored. */
43
50
  }
44
51
  /** Standard IMAP special-use folder types */
45
52
  export type SpecialUse = "inbox" | "sent" | "drafts" | "trash" | "junk" | "archive" | "all";