@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 +2 -0
- package/client/app.js +27 -3
- package/client/compose/compose.js +12 -0
- package/client/index.html +1 -1
- package/client/package.json +1 -1
- package/package.json +1 -1
- package/packages/mailx-store/db.d.ts +12 -12
- package/packages/mailx-store/db.js +116 -30
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
|
|
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="
|
|
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">
|
package/client/package.json
CHANGED
package/package.json
CHANGED
|
@@ -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
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
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
|
-
*
|
|
1103
|
-
*
|
|
1104
|
-
*
|
|
1105
|
-
*
|
|
1106
|
-
*
|
|
1107
|
-
*
|
|
1108
|
-
*
|
|
1109
|
-
*
|
|
1110
|
-
*
|
|
1111
|
-
*
|
|
1112
|
-
*
|
|
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
|
-
//
|
|
1117
|
-
//
|
|
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
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
1161
|
-
const
|
|
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 === "
|
|
1169
|
-
|
|
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 (
|
|
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 ?
|