@bobfrankston/mailx-store 0.1.74 → 0.1.78
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/db.d.ts +6 -2
- package/db.js +89 -17
- package/package.json +1 -1
package/db.d.ts
CHANGED
|
@@ -274,8 +274,12 @@ export declare class MailxDB {
|
|
|
274
274
|
* During the additive migration we ALSO have to handle the legacy
|
|
275
275
|
* case where the row's folder_id matches but no message_folders row
|
|
276
276
|
* was migrated for it (race during initial population, etc.) — we
|
|
277
|
-
* fall back to the messages.folder_id check to clean up consistently.
|
|
278
|
-
|
|
277
|
+
* fall back to the messages.folder_id check to clean up consistently.
|
|
278
|
+
*
|
|
279
|
+
* `reason`/`source` override the audit text — deleteMessage routes user
|
|
280
|
+
* deletes through here, and "reconcile: server missing this UID" would
|
|
281
|
+
* be a lie in that audit trail. */
|
|
282
|
+
dropFolderMembership(accountId: string, folderId: number, uid: number, folderPath?: string, reason?: string, source?: string): boolean;
|
|
279
283
|
upsertMessage(msg: {
|
|
280
284
|
accountId: string;
|
|
281
285
|
folderId: number;
|
package/db.js
CHANGED
|
@@ -1675,8 +1675,12 @@ export class MailxDB {
|
|
|
1675
1675
|
* During the additive migration we ALSO have to handle the legacy
|
|
1676
1676
|
* case where the row's folder_id matches but no message_folders row
|
|
1677
1677
|
* was migrated for it (race during initial population, etc.) — we
|
|
1678
|
-
* fall back to the messages.folder_id check to clean up consistently.
|
|
1679
|
-
|
|
1678
|
+
* fall back to the messages.folder_id check to clean up consistently.
|
|
1679
|
+
*
|
|
1680
|
+
* `reason`/`source` override the audit text — deleteMessage routes user
|
|
1681
|
+
* deletes through here, and "reconcile: server missing this UID" would
|
|
1682
|
+
* be a lie in that audit trail. */
|
|
1683
|
+
dropFolderMembership(accountId, folderId, uid, folderPath, reason, source) {
|
|
1680
1684
|
// First find the messages row this membership pointed at.
|
|
1681
1685
|
const mf = this.db.prepare("SELECT message_row_id FROM message_folders WHERE folder_id = ? AND uid = ?").get(folderId, uid);
|
|
1682
1686
|
let rowId = null;
|
|
@@ -1711,8 +1715,8 @@ export class MailxDB {
|
|
|
1711
1715
|
this.audit({
|
|
1712
1716
|
kind: "delete-membership",
|
|
1713
1717
|
accountId, folderId, uid,
|
|
1714
|
-
reason: `reconcile dropped folder membership; ${remaining.cnt} other location(s) remain`,
|
|
1715
|
-
source: `dropFolderMembership (${folderPath || ""})`,
|
|
1718
|
+
reason: reason || `reconcile dropped folder membership; ${remaining.cnt} other location(s) remain`,
|
|
1719
|
+
source: source || `dropFolderMembership (${folderPath || ""})`,
|
|
1716
1720
|
});
|
|
1717
1721
|
return true;
|
|
1718
1722
|
}
|
|
@@ -1726,8 +1730,8 @@ export class MailxDB {
|
|
|
1726
1730
|
accountId, folderId, uid,
|
|
1727
1731
|
messageId: env.message_id || undefined,
|
|
1728
1732
|
subject: env.subject || undefined,
|
|
1729
|
-
reason: "reconcile: server missing this UID after grace, no other folder memberships",
|
|
1730
|
-
source: `dropFolderMembership (${folderPath || ""})`,
|
|
1733
|
+
reason: reason || "reconcile: server missing this UID after grace, no other folder memberships",
|
|
1734
|
+
source: source || `dropFolderMembership (${folderPath || ""})`,
|
|
1731
1735
|
});
|
|
1732
1736
|
console.log(` [reconcile-delete] ${accountId} ${folderPath || folderId}/${uid} msgid=${env.message_id || "?"} (no other memberships) — body ${env.body_path || "(none)"}`);
|
|
1733
1737
|
}
|
|
@@ -2310,13 +2314,19 @@ export class MailxDB {
|
|
|
2310
2314
|
* hitting the message in `_Spam` (correct) and hitting nothing
|
|
2311
2315
|
* because the legacy messages.folder_id still points at INBOX. */
|
|
2312
2316
|
getMessageByUid(accountId, uid, folderId) {
|
|
2317
|
+
// Alias mf.folder_id over m.folder_id too — the envelope must carry
|
|
2318
|
+
// the identity of the membership that MATCHED, not the legacy primary
|
|
2319
|
+
// location, or a caller doing delete/move/flag with the returned
|
|
2320
|
+
// folderId operates on a different folder than the one it looked up
|
|
2321
|
+
// (the two drift: local moves keep the original uid, re-binds update
|
|
2322
|
+
// one table but not the other).
|
|
2313
2323
|
const sql = folderId != null
|
|
2314
|
-
? `SELECT m.*, mf.uid AS uid
|
|
2324
|
+
? `SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id
|
|
2315
2325
|
FROM messages m
|
|
2316
2326
|
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
2317
2327
|
WHERE m.account_id = ? AND mf.uid = ? AND mf.folder_id = ?
|
|
2318
2328
|
LIMIT 1`
|
|
2319
|
-
: `SELECT m.*, mf.uid AS uid
|
|
2329
|
+
: `SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id
|
|
2320
2330
|
FROM messages m
|
|
2321
2331
|
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
2322
2332
|
WHERE m.account_id = ? AND mf.uid = ?
|
|
@@ -2884,14 +2894,31 @@ export class MailxDB {
|
|
|
2884
2894
|
* Reason is propagated into the audit_log so the DB carries an
|
|
2885
2895
|
* authoritative trail of every removal. */
|
|
2886
2896
|
deleteMessage(accountId, folderId, uid, reason, source) {
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2897
|
+
if (folderId != null) {
|
|
2898
|
+
// Resolve through message_folders — the SAME identity that
|
|
2899
|
+
// getMessageByUid and every list query use. The legacy
|
|
2900
|
+
// messages.folder_id/uid columns can drift from the membership
|
|
2901
|
+
// rows (local moves keep the original uid, server re-binds update
|
|
2902
|
+
// one table but not the other), and a delete keyed only on the
|
|
2903
|
+
// legacy columns silently matches ZERO rows on a drifted message:
|
|
2904
|
+
// the membership keeps it visible in the UI and it becomes
|
|
2905
|
+
// undeletable forever (Eleanor 2026-07-29: ElkinWs uid 125482 in
|
|
2906
|
+
// Trash "refuses to delete" — every attempt no-op'd here while
|
|
2907
|
+
// the sync action reported success). dropFolderMembership drops
|
|
2908
|
+
// the membership, orphan-cleans the messages row, and falls back
|
|
2909
|
+
// to the legacy columns for pre-migration rows.
|
|
2910
|
+
const cleaned = this.dropFolderMembership(accountId, folderId, uid, undefined, reason || "deleteMessage (no reason)", source || "db.deleteMessage");
|
|
2911
|
+
if (!cleaned) {
|
|
2912
|
+
// A delete that deletes nothing is the "refuses to delete"
|
|
2913
|
+
// symptom — never let it pass silently.
|
|
2914
|
+
console.error(` [delete-miss] ${accountId} folder=${folderId} uid=${uid}: no membership or legacy row matched — nothing deleted (source=${source || "db.deleteMessage"})`);
|
|
2915
|
+
}
|
|
2916
|
+
this.recalcFolderCounts(folderId);
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
// No folderId (legacy callers): fall back to the messages-table key.
|
|
2920
|
+
const msg = this.db.prepare("SELECT id, folder_id, message_id, subject FROM messages WHERE account_id = ? AND uid = ?").get(accountId, uid);
|
|
2921
|
+
const r = this.db.prepare("DELETE FROM messages WHERE account_id = ? AND uid = ?").run(accountId, uid);
|
|
2895
2922
|
if (r.changes && msg) {
|
|
2896
2923
|
this.audit({
|
|
2897
2924
|
kind: "delete-msg",
|
|
@@ -2904,6 +2931,9 @@ export class MailxDB {
|
|
|
2904
2931
|
source: source || "db.deleteMessage",
|
|
2905
2932
|
});
|
|
2906
2933
|
}
|
|
2934
|
+
else if (!r.changes) {
|
|
2935
|
+
console.error(` [delete-miss] ${accountId} uid=${uid} (no folderId): no messages row matched — nothing deleted (source=${source || "db.deleteMessage"})`);
|
|
2936
|
+
}
|
|
2907
2937
|
// Refresh folder counts
|
|
2908
2938
|
if (msg)
|
|
2909
2939
|
this.recalcFolderCounts(msg.folder_id);
|
|
@@ -3674,8 +3704,26 @@ export class MailxDB {
|
|
|
3674
3704
|
const LIKE_CC = ["m.cc_json"];
|
|
3675
3705
|
const LIKE_SUBJ = ["m.subject"];
|
|
3676
3706
|
const LIKE_ANY = ["m.subject", "m.from_name", "m.from_address"];
|
|
3707
|
+
// `NOT <item>` — exclusion, usable ANYWHERE including as the leading
|
|
3708
|
+
// (or only) token. FTS5's own NOT is strictly binary (`a NOT b`), so
|
|
3709
|
+
// a leading NOT was an FTS5 syntax error that the dangling-operator
|
|
3710
|
+
// trim "fixed" by dropping the NOT — silently searching FOR the term
|
|
3711
|
+
// the user asked to exclude (Bob 2026-07-30). Instead of leaning on
|
|
3712
|
+
// FTS5's operator, every `NOT x` pair is lifted OUT of the MATCH and
|
|
3713
|
+
// applied as a SQL-side exclusion (`m.id NOT IN (SELECT rowid …
|
|
3714
|
+
// MATCH x)`), which also negates qualifier tokens (`NOT from:…`,
|
|
3715
|
+
// `NOT is:read`) uniformly. Uppercase only, same as AND/OR — a
|
|
3716
|
+
// lowercase "not" stays an ordinary search term.
|
|
3717
|
+
const notFrags = [];
|
|
3718
|
+
let negateNext = false;
|
|
3677
3719
|
for (let pi = 0; pi < parts.length; pi++) {
|
|
3678
3720
|
const part = parts[pi];
|
|
3721
|
+
if (part === "NOT") {
|
|
3722
|
+
negateNext = true;
|
|
3723
|
+
continue;
|
|
3724
|
+
}
|
|
3725
|
+
const wPre = extraWhere.length;
|
|
3726
|
+
const fPre = frags.length;
|
|
3679
3727
|
// A 1-2 char term next to a user-typed OR must be DROPPED, not
|
|
3680
3728
|
// LIKE'd: LIKE clauses AND against the MATCH, so "ab OR sprinkler"
|
|
3681
3729
|
// would intersect down to zero. Over-matching (just "sprinkler")
|
|
@@ -3796,12 +3844,14 @@ export class MailxDB {
|
|
|
3796
3844
|
extraWhere.push("LOWER(f.name) LIKE ?");
|
|
3797
3845
|
extraParams.push(`%${v.toLowerCase()}%`);
|
|
3798
3846
|
}
|
|
3799
|
-
else if (/^(AND|OR
|
|
3847
|
+
else if (/^(AND|OR)$/.test(part)) {
|
|
3800
3848
|
// FTS5 boolean operators — pass through verbatim (must be uppercase).
|
|
3801
3849
|
// Without this branch, `hoddie AND git` got wildcarded into
|
|
3802
3850
|
// `hoddie* AND* git*`, which FTS5 reads as three required terms
|
|
3803
3851
|
// (one of them being any word starting with "AND") — so a real
|
|
3804
3852
|
// match like "Peter Hoddie" + "github" returned zero hits.
|
|
3853
|
+
// (NOT is handled above as an exclusion prefix, not passed
|
|
3854
|
+
// through — FTS5's binary-only NOT can't stand at the front.)
|
|
3805
3855
|
frags.push({ s: part, op: true });
|
|
3806
3856
|
}
|
|
3807
3857
|
else if (isTri) {
|
|
@@ -3863,6 +3913,20 @@ export class MailxDB {
|
|
|
3863
3913
|
}
|
|
3864
3914
|
}
|
|
3865
3915
|
}
|
|
3916
|
+
// Divert whatever this token contributed into the exclusion set.
|
|
3917
|
+
// FTS frags move to notFrags (applied as a NOT IN subquery below);
|
|
3918
|
+
// SQL-side qualifier clauses get wrapped in NOT(...) in place. An
|
|
3919
|
+
// AND/OR right after NOT is nonsense input — the op frag stands
|
|
3920
|
+
// and the stray NOT is dropped.
|
|
3921
|
+
if (negateNext) {
|
|
3922
|
+
if (!/^(AND|OR)$/.test(part)) {
|
|
3923
|
+
for (let w = wPre; w < extraWhere.length; w++)
|
|
3924
|
+
extraWhere[w] = `NOT (${extraWhere[w]})`;
|
|
3925
|
+
while (frags.length > fPre)
|
|
3926
|
+
notFrags.push(frags.pop().s);
|
|
3927
|
+
}
|
|
3928
|
+
negateNext = false;
|
|
3929
|
+
}
|
|
3866
3930
|
}
|
|
3867
3931
|
// A term diverted to the LIKE fallback can strand a user-typed
|
|
3868
3932
|
// operator at the edge of the MATCH string ("ab OR sprinkler" → the
|
|
@@ -3872,6 +3936,14 @@ export class MailxDB {
|
|
|
3872
3936
|
frags.shift();
|
|
3873
3937
|
while (frags.length && frags[frags.length - 1].op)
|
|
3874
3938
|
frags.pop();
|
|
3939
|
+
// NOT'd terms exclude via a subquery on the same FTS index: a message
|
|
3940
|
+
// matching ANY excluded term is out. Works whether or not there are
|
|
3941
|
+
// positive FTS terms — a pure `NOT foo` query takes the qualifier-only
|
|
3942
|
+
// path below and returns everything except matches.
|
|
3943
|
+
if (notFrags.length > 0) {
|
|
3944
|
+
extraWhere.push("m.id NOT IN (SELECT rowid FROM messages_fts WHERE messages_fts MATCH ?)");
|
|
3945
|
+
extraParams.push(notFrags.join(" OR "));
|
|
3946
|
+
}
|
|
3875
3947
|
// Join fragments. Two adjacent value fragments need an explicit `AND`
|
|
3876
3948
|
// (implicit-AND after a `(...)` / `{...}:` group is an FTS5 syntax
|
|
3877
3949
|
// error); a user operator fragment glues itself, so no insert around it.
|