@bobfrankston/mailx 1.0.435 → 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 +64 -14
- package/client/components/message-viewer.js +24 -10
- package/client/lib/api-client.js +3 -0
- package/client/lib/mailxapi.js +3 -0
- package/package.json +1 -1
- package/packages/mailx-service/index.d.ts +1 -0
- package/packages/mailx-service/index.js +11 -0
- package/packages/mailx-service/jsonrpc.js +2 -0
- package/packages/mailx-store/db.js +22 -7
- package/unwedge.cmd +1 -1
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
|
|
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
|
}
|
|
@@ -2219,7 +2225,7 @@ document.getElementById("btn-open-log")?.addEventListener("click", async () => {
|
|
|
2219
2225
|
}
|
|
2220
2226
|
});
|
|
2221
2227
|
async function openJsoncEditor(initialFile) {
|
|
2222
|
-
const { readJsoncFile, writeJsoncFile, readConfigHelp } = await import("./lib/api-client.js");
|
|
2228
|
+
const { readJsoncFile, writeJsoncFile, readConfigHelp, formatJsonc } = await import("./lib/api-client.js");
|
|
2223
2229
|
const backdrop = document.createElement("div");
|
|
2224
2230
|
backdrop.className = "mailx-modal-backdrop";
|
|
2225
2231
|
const panel = document.createElement("div");
|
|
@@ -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>
|
|
@@ -2253,6 +2260,7 @@ async function openJsoncEditor(initialFile) {
|
|
|
2253
2260
|
</div>
|
|
2254
2261
|
<div class="mailx-modal-error" id="jsonc-error" hidden></div>
|
|
2255
2262
|
<div class="mailx-modal-buttons">
|
|
2263
|
+
<button type="button" class="mailx-modal-btn" data-action="format" title="Reformat indentation while preserving comments and trailing commas">Format</button>
|
|
2256
2264
|
<span class="mailx-modal-spacer"></span>
|
|
2257
2265
|
<button type="button" class="mailx-modal-btn" data-action="cancel">Cancel</button>
|
|
2258
2266
|
<button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="save">Save</button>
|
|
@@ -2311,17 +2319,34 @@ async function openJsoncEditor(initialFile) {
|
|
|
2311
2319
|
renderGutter();
|
|
2312
2320
|
};
|
|
2313
2321
|
const showValidation = (err) => {
|
|
2314
|
-
|
|
2322
|
+
// CRITICAL: do NOT move the cursor here. Validation fires every 600ms
|
|
2323
|
+
// while the user types; auto-selecting the error position yanked the
|
|
2324
|
+
// cursor mid-edit and made fixing the error impossible (the user
|
|
2325
|
+
// reported this as a fatal bug — the very mechanism preventing a save
|
|
2326
|
+
// was preventing the fix). Location is shown via the gutter highlight
|
|
2327
|
+
// + the "Line N, col M" message, and the user can click "Jump" to
|
|
2328
|
+
// explicitly navigate.
|
|
2329
|
+
errorEl.innerHTML = "";
|
|
2330
|
+
const text = document.createElement("span");
|
|
2331
|
+
text.textContent = `Line ${err.line}, col ${err.col}: ${err.message} `;
|
|
2332
|
+
const jumpBtn = document.createElement("button");
|
|
2333
|
+
jumpBtn.type = "button";
|
|
2334
|
+
jumpBtn.className = "mailx-modal-btn mailx-modal-btn-link";
|
|
2335
|
+
jumpBtn.textContent = "Jump to error";
|
|
2336
|
+
jumpBtn.addEventListener("click", () => {
|
|
2337
|
+
textarea.focus();
|
|
2338
|
+
try {
|
|
2339
|
+
textarea.setSelectionRange(err.pos, err.pos + 1);
|
|
2340
|
+
}
|
|
2341
|
+
catch { /* */ }
|
|
2342
|
+
});
|
|
2343
|
+
errorEl.appendChild(text);
|
|
2344
|
+
errorEl.appendChild(jumpBtn);
|
|
2315
2345
|
errorEl.hidden = false;
|
|
2316
2346
|
textarea.classList.add("mailx-modal-input-error");
|
|
2317
2347
|
saveBtn.disabled = true;
|
|
2318
2348
|
errorLine = err.line;
|
|
2319
2349
|
renderGutter();
|
|
2320
|
-
// Select the problem character so the browser draws a visible marker
|
|
2321
|
-
try {
|
|
2322
|
-
textarea.setSelectionRange(err.pos, err.pos + 1);
|
|
2323
|
-
}
|
|
2324
|
-
catch { /* out-of-range → ignore */ }
|
|
2325
2350
|
};
|
|
2326
2351
|
let validateTimer;
|
|
2327
2352
|
const scheduleValidate = () => {
|
|
@@ -2377,6 +2402,31 @@ async function openJsoncEditor(initialFile) {
|
|
|
2377
2402
|
close();
|
|
2378
2403
|
return;
|
|
2379
2404
|
}
|
|
2405
|
+
if (action === "format") {
|
|
2406
|
+
// Reformat via the service-side jsonc-parser format() — the
|
|
2407
|
+
// edits are whitespace-only, so `//` and `/* */` comments
|
|
2408
|
+
// survive intact (which JSON.stringify(parse(...)) does not).
|
|
2409
|
+
btn.disabled = true;
|
|
2410
|
+
const orig = btn.textContent;
|
|
2411
|
+
btn.textContent = "Formatting…";
|
|
2412
|
+
try {
|
|
2413
|
+
const r = await formatJsonc(textarea.value);
|
|
2414
|
+
if (r?.content !== undefined) {
|
|
2415
|
+
textarea.value = r.content;
|
|
2416
|
+
renderGutter();
|
|
2417
|
+
scheduleValidate();
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
catch (e) {
|
|
2421
|
+
errorEl.textContent = `Format failed: ${e.message}`;
|
|
2422
|
+
errorEl.hidden = false;
|
|
2423
|
+
}
|
|
2424
|
+
finally {
|
|
2425
|
+
btn.disabled = false;
|
|
2426
|
+
btn.textContent = orig || "Format";
|
|
2427
|
+
}
|
|
2428
|
+
return;
|
|
2429
|
+
}
|
|
2380
2430
|
if (action === "save") {
|
|
2381
2431
|
// Final sync-check; refuse to save if it doesn't parse
|
|
2382
2432
|
const err = validateJsonc(textarea.value);
|
|
@@ -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
|
-
// -
|
|
362
|
-
//
|
|
363
|
-
//
|
|
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/client/lib/api-client.js
CHANGED
|
@@ -339,6 +339,9 @@ export function readJsoncFile(name) {
|
|
|
339
339
|
export function writeJsoncFile(name, content) {
|
|
340
340
|
return ipc().writeJsoncFile?.(name, content);
|
|
341
341
|
}
|
|
342
|
+
export function formatJsonc(content) {
|
|
343
|
+
return ipc().formatJsonc?.(content);
|
|
344
|
+
}
|
|
342
345
|
export function readConfigHelp(name) {
|
|
343
346
|
return ipc().readConfigHelp?.(name) ?? Promise.resolve({ content: "" });
|
|
344
347
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -131,6 +131,9 @@
|
|
|
131
131
|
writeJsoncFile: function(name, content) {
|
|
132
132
|
return callNode("writeJsoncFile", { name: name, content: content });
|
|
133
133
|
},
|
|
134
|
+
formatJsonc: function(content) {
|
|
135
|
+
return callNode("formatJsonc", { content: content });
|
|
136
|
+
},
|
|
134
137
|
readConfigHelp: function(name) {
|
|
135
138
|
return callNode("readConfigHelp", { name: name });
|
|
136
139
|
},
|
package/package.json
CHANGED
|
@@ -252,6 +252,7 @@ export declare class MailxService {
|
|
|
252
252
|
* Names are whitelisted so the UI can't read arbitrary files.
|
|
253
253
|
* `config.jsonc` is the local per-machine config (not cloud-synced). */
|
|
254
254
|
readJsoncFile(name: string): Promise<string | null>;
|
|
255
|
+
formatJsonc(content: string): Promise<string>;
|
|
255
256
|
/** Return the help section for a named config file, extracted from docs/config-help.md.
|
|
256
257
|
* Matches a level-2 heading whose text equals the filename. Returns markdown. */
|
|
257
258
|
readConfigHelp(name: string): Promise<string>;
|
|
@@ -1762,6 +1762,17 @@ export class MailxService {
|
|
|
1762
1762
|
const { cloudRead } = await import("@bobfrankston/mailx-settings");
|
|
1763
1763
|
return cloudRead(name);
|
|
1764
1764
|
}
|
|
1765
|
+
// Reformat JSONC preserving comments — applyEdits returns whitespace-only edits.
|
|
1766
|
+
async formatJsonc(content) {
|
|
1767
|
+
const { format, applyEdits } = await import("jsonc-parser");
|
|
1768
|
+
const edits = format(content, undefined, {
|
|
1769
|
+
tabSize: 2,
|
|
1770
|
+
insertSpaces: true,
|
|
1771
|
+
eol: "\n",
|
|
1772
|
+
insertFinalNewline: true,
|
|
1773
|
+
});
|
|
1774
|
+
return applyEdits(content, edits);
|
|
1775
|
+
}
|
|
1765
1776
|
/** Return the help section for a named config file, extracted from docs/config-help.md.
|
|
1766
1777
|
* Matches a level-2 heading whose text equals the filename. Returns markdown. */
|
|
1767
1778
|
async readConfigHelp(name) {
|
|
@@ -169,6 +169,8 @@ async function dispatchAction(svc, action, p) {
|
|
|
169
169
|
case "writeJsoncFile":
|
|
170
170
|
await svc.writeJsoncFile(p.name, p.content);
|
|
171
171
|
return { ok: true };
|
|
172
|
+
case "formatJsonc":
|
|
173
|
+
return { content: await svc.formatJsonc(p.content) };
|
|
172
174
|
case "readConfigHelp":
|
|
173
175
|
return { content: await svc.readConfigHelp(p.name) };
|
|
174
176
|
case "unsubscribeOneClick":
|
|
@@ -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
|
-
|
|
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
|
|
1474
|
+
WHERE ${tokenWhere}
|
|
1460
1475
|
ORDER BY match_rank DESC, use_count DESC, last_used DESC
|
|
1461
|
-
LIMIT ?`).all(
|
|
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
|
|
1482
|
+
WHERE ${tokenWhere}
|
|
1468
1483
|
ORDER BY use_count DESC, last_used DESC
|
|
1469
|
-
LIMIT ?`).all(
|
|
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
|
package/unwedge.cmd
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
rmdir C:\Users\Bob\.claude\session-env\
|
|
1
|
+
rmdir C:\Users\Bob\.claude\session-env\6787e337-1af0-423c-ae58-8d981702aebb /s /q
|