@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 +2 -0
- package/client/app.js +27 -3
- package/client/compose/compose.js +34 -7
- package/client/index.html +1 -1
- package/client/package.json +1 -1
- package/package.json +3 -3
- package/packages/mailx-settings/index.js +2 -0
- package/packages/mailx-store/db.d.ts +12 -1
- package/packages/mailx-store/db.js +156 -11
- package/packages/mailx-types/index.d.ts +8 -1
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");
|
|
@@ -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.
|
|
453
|
-
//
|
|
454
|
-
//
|
|
452
|
+
// the body. Two sources, in priority order:
|
|
453
|
+
// 1. New `sig: { text, html? }` object — applied 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
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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="
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
-
|
|
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
|
|
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";
|