@bobfrankston/mailx 1.0.436 → 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 +14 -7
- package/client/components/message-viewer.js +24 -10
- package/package.json +1 -1
- package/packages/mailx-store/db.js +22 -7
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
|
}
|
|
@@ -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>
|
|
@@ -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/package.json
CHANGED
|
@@ -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
|