@bobfrankston/mailx 1.0.430 → 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");
@@ -974,7 +974,13 @@ function formatSize(n) {
974
974
  return `${(n / 1024).toFixed(1)} KB`;
975
975
  return `${(n / (1024 * 1024)).toFixed(1)} MB`;
976
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;
977
982
  document.getElementById("btn-attach")?.addEventListener("click", () => {
983
+ attachJustClicked = Date.now();
978
984
  fileInput?.click();
979
985
  });
980
986
  async function ingestFiles(files) {
@@ -1065,6 +1071,12 @@ document.addEventListener("keydown", (e) => {
1065
1071
  document.getElementById("btn-send")?.click();
1066
1072
  }
1067
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;
1068
1080
  e.preventDefault();
1069
1081
  handleCloseRequest();
1070
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.430",
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",
@@ -196,18 +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 every address that appears in any cached message
200
- * From, To, Cc, Bcc across all folders. The original implementation
201
- * only scanned `from_address`, so addresses you'd sent to (To/Cc on
202
- * outgoing mail) never made it into autocomplete unless they had also
203
- * written back to you. That left compose unable to suggest people you
204
- * email regularly but who never reply. This pass walks the JSON arrays
205
- * too. Dedup by lowercased email; per-address `use_count` is the total
206
- * occurrences across the corpus, `last_used` is the most recent message
207
- * date in any field. Existing rows have their counters bumped (instead of
208
- * the old skip-if-exists behavior) so periodic re-seeds keep recency
209
- * fresh `received`-tagged rows get usage data from observed traffic;
210
- * `sent` and `google` rows are left alone (they're authoritative). */
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. */
211
211
  seedContactsFromMessages(): number;
212
212
  /** Search contacts by name or email prefix */
213
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,28 +1119,31 @@ 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 every address that appears in any cached message
1102
- * From, To, Cc, Bcc across all folders. The original implementation
1103
- * only scanned `from_address`, so addresses you'd sent to (To/Cc on
1104
- * outgoing mail) never made it into autocomplete unless they had also
1105
- * written back to you. That left compose unable to suggest people you
1106
- * email regularly but who never reply. This pass walks the JSON arrays
1107
- * too. Dedup by lowercased email; per-address `use_count` is the total
1108
- * occurrences across the corpus, `last_used` is the most recent message
1109
- * date in any field. Existing rows have their counters bumped (instead of
1110
- * the old skip-if-exists behavior) so periodic re-seeds keep recency
1111
- * fresh `received`-tagged rows get usage data from observed traffic;
1112
- * `sent` and `google` rows are left alone (they're authoritative). */
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. */
1113
1134
  seedContactsFromMessages() {
1114
1135
  const VALID = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
1115
1136
  const now = Date.now();
1116
- // Per-email aggregator. `name` keeps the first non-empty display name
1117
- // we see so autocomplete shows "Alice <alice@…>" not just the address.
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).
1118
1140
  const agg = new Map();
1119
- const bump = (name, address, date) => {
1141
+ const bump = (name, address, date, tag) => {
1120
1142
  const email = (address || "").trim().toLowerCase();
1121
1143
  if (!email || !VALID.test(email))
1122
1144
  return;
1145
+ if (isJunkContact(email, name))
1146
+ return;
1123
1147
  const e = agg.get(email);
1124
1148
  if (e) {
1125
1149
  e.cnt++;
@@ -1127,15 +1151,20 @@ export class MailxDB {
1127
1151
  e.last = date;
1128
1152
  if (!e.name && name)
1129
1153
  e.name = name;
1154
+ if (tag === "sent")
1155
+ e.tag = "sent"; // upgrade
1130
1156
  }
1131
1157
  else {
1132
- agg.set(email, { name: name || "", cnt: 1, last: date || 0 });
1158
+ agg.set(email, { name: name || "", cnt: 1, last: date || 0, tag });
1133
1159
  }
1134
1160
  };
1135
- const rows = this.db.prepare("SELECT from_name, from_address, to_json, cc_json, bcc_json, date FROM messages").all();
1136
- for (const r of rows) {
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) {
1137
1167
  const date = r.date || 0;
1138
- bump(r.from_name, r.from_address, date);
1139
1168
  for (const field of [r.to_json, r.cc_json, r.bcc_json]) {
1140
1169
  if (!field)
1141
1170
  continue;
@@ -1151,30 +1180,70 @@ export class MailxDB {
1151
1180
  for (const a of parsed) {
1152
1181
  if (!a)
1153
1182
  continue;
1154
- bump(a.name || "", a.address || a.email || "", date);
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");
1155
1211
  }
1156
1212
  }
1157
1213
  }
1158
1214
  let added = 0;
1159
1215
  let bumped = 0;
1160
- const insStmt = this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('received', ?, ?, ?, ?, ?)");
1161
- const updStmt = this.db.prepare("UPDATE contacts SET use_count = ?, last_used = max(last_used, ?), name = CASE WHEN name = '' AND ? != '' THEN ? ELSE name END, updated_at = ? WHERE email = ? AND source = 'received'");
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')`);
1162
1227
  for (const [email, info] of agg) {
1163
1228
  const existing = this.db.prepare("SELECT id, source FROM contacts WHERE email = ?").get(email);
1164
1229
  if (!existing) {
1165
- insStmt.run(info.name, email, info.last, info.cnt, now);
1230
+ insStmt.run(info.tag, info.name, email, info.last, info.cnt, now);
1166
1231
  added++;
1167
1232
  }
1168
- else if (existing.source === "received") {
1169
- updStmt.run(info.cnt, info.last, info.name, info.name, now, email);
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);
1170
1241
  bumped++;
1171
1242
  }
1172
- // 'sent' and 'google' rows are authoritative — leave their
1173
- // use_count alone. recordSentAddress bumps 'sent' rows on actual
1174
- // sends, syncGoogleContacts owns 'google' rows.
1175
1243
  }
1176
- if (bumped > 0)
1177
- console.log(` [contacts] seed: ${added} new + ${bumped} refreshed`);
1244
+ if (added > 0 || upgraded > 0) {
1245
+ console.log(` [contacts] seed: ${added} new + ${bumped} refreshed (${upgraded} upgraded received→sent)`);
1246
+ }
1178
1247
  return added;
1179
1248
  }
1180
1249
  /** Search contacts by name or email prefix */
@@ -1194,12 +1263,29 @@ export class MailxDB {
1194
1263
  let rows;
1195
1264
  try {
1196
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.
1197
1278
  rows = this.db.prepare(`SELECT name, email, source, use_count, last_used,
1198
1279
  (CASE
1199
1280
  WHEN lower(name) LIKE lower(?) THEN 3
1200
1281
  WHEN substr(email, 1, instr(email, '@') - 1) LIKE lower(?) THEN 2
1201
1282
  WHEN email LIKE ? OR name LIKE ? THEN 1
1202
1283
  ELSE 0
1284
+ END) +
1285
+ (CASE source
1286
+ WHEN 'google' THEN 30
1287
+ WHEN 'sent' THEN 20
1288
+ ELSE 0
1203
1289
  END) AS match_rank
1204
1290
  FROM contacts
1205
1291
  WHERE email LIKE ? OR name LIKE ?