@bobfrankston/mailx-store 0.1.15 → 0.1.17
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 +22 -0
- package/db.js +165 -19
- package/package.json +3 -3
package/db.d.ts
CHANGED
|
@@ -252,6 +252,28 @@ export declare class MailxDB {
|
|
|
252
252
|
getMessageBodyPath(accountId: string, uid: number): string;
|
|
253
253
|
updateMessageFlags(accountId: string, uid: number, flags: string[]): void;
|
|
254
254
|
updateMessageFolder(accountId: string, uid: number, targetFolderId: number): void;
|
|
255
|
+
/** Local-first move of a message between folders. Updates BOTH the
|
|
256
|
+
* legacy `messages.folder_id` column AND the `message_folders`
|
|
257
|
+
* membership row, so user-visible queries (which join through mf)
|
|
258
|
+
* immediately reflect the move. The same uid is preserved on the
|
|
259
|
+
* destination side — once the IMAP MOVE drains and reconcile runs,
|
|
260
|
+
* the membership uid is rebound to whatever the server assigned in
|
|
261
|
+
* the new folder (move-detect by Message-ID does this).
|
|
262
|
+
*
|
|
263
|
+
* Returns true if the message was found and moved, false otherwise.
|
|
264
|
+
* Pre-fix `updateMessageFolder` only touched the legacy column, so
|
|
265
|
+
* user-visible folder lists (which join through `message_folders`)
|
|
266
|
+
* stayed showing the old location until the next reconcile.
|
|
267
|
+
* Symptom: drag-to-folder doesn't visually arrive, Ctrl+Z undelete
|
|
268
|
+
* doesn't restore until sync. */
|
|
269
|
+
moveMessageLocal(accountId: string, uid: number, fromFolderId: number, toFolderId: number): boolean;
|
|
270
|
+
/** Find a pending sync action by its identifying fields. Used by the
|
|
271
|
+
* undelete path to detect "is the to-trash MOVE still queued?" — if
|
|
272
|
+
* yes, cancelling it (completeSyncAction) is enough to undelete; the
|
|
273
|
+
* server never sees the move. */
|
|
274
|
+
findPendingSyncAction(accountId: string, action: string, uid: number, folderId: number, targetFolderId?: number): {
|
|
275
|
+
id: number;
|
|
276
|
+
} | null;
|
|
255
277
|
updateBodyPath(accountId: string, uid: number, bodyPath: string): void;
|
|
256
278
|
/** Get messages without cached bodies (for background prefetch) */
|
|
257
279
|
getMessagesWithoutBody(accountId: string, limit?: number): {
|
package/db.js
CHANGED
|
@@ -7,7 +7,29 @@ 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
|
+
// libmime has no @types; declare module for the one symbol we use.
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
12
|
+
const _libmimeMod = await import("libmime");
|
|
10
13
|
import { CONTACT_RULES } from "@bobfrankston/mailx-types";
|
|
14
|
+
/** RFC 2047 encoded-word decode. mailparser handles the standard cases
|
|
15
|
+
* (Subject, unquoted display names) but leaves encoded-words inside
|
|
16
|
+
* quoted-strings raw because they're RFC-strictly forbidden there —
|
|
17
|
+
* in practice senders do it anyway. Running libmime.decodeWords as a
|
|
18
|
+
* uniform pass over every header string at the storage seam means
|
|
19
|
+
* every code path (IMAP fetch, Gmail API, manual insert, eml replay)
|
|
20
|
+
* gets the same treatment. Safe on already-decoded strings (no `=?`
|
|
21
|
+
* markers → no-op). */
|
|
22
|
+
const _libmime = _libmimeMod.default || _libmimeMod;
|
|
23
|
+
function decodeHeaderWords(s) {
|
|
24
|
+
if (!s || s.indexOf("=?") < 0)
|
|
25
|
+
return s || "";
|
|
26
|
+
try {
|
|
27
|
+
return _libmime.decodeWords(s);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return s;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
11
33
|
/** Addresses that have no business in autocomplete. Patterns load from
|
|
12
34
|
* contact-rules.jsonc in mailx-types — a single source of truth shipped
|
|
13
35
|
* with every release. To change the rules edit the JSONC and rebuild. */
|
|
@@ -1191,6 +1213,20 @@ export class MailxDB {
|
|
|
1191
1213
|
return true;
|
|
1192
1214
|
}
|
|
1193
1215
|
upsertMessage(msg) {
|
|
1216
|
+
// Uniform RFC 2047 encoded-word decode at the storage seam. Every
|
|
1217
|
+
// path that lands a message here (IMAP fetch, Gmail API, manual
|
|
1218
|
+
// insert) gets the same treatment — no per-callsite patches.
|
|
1219
|
+
// mailparser handles many cases but not encoded-words inside
|
|
1220
|
+
// quoted-strings (RFC-strictly forbidden, common in the wild). Run
|
|
1221
|
+
// libmime over Subject and every address display name once; safe
|
|
1222
|
+
// to call on already-decoded text (no `=?...?=` markers → no-op).
|
|
1223
|
+
msg.subject = decodeHeaderWords(msg.subject);
|
|
1224
|
+
if (msg.from)
|
|
1225
|
+
msg.from = { name: decodeHeaderWords(msg.from.name || ""), address: msg.from.address || "" };
|
|
1226
|
+
if (msg.to)
|
|
1227
|
+
msg.to = msg.to.map(a => ({ name: decodeHeaderWords(a.name || ""), address: a.address || "" }));
|
|
1228
|
+
if (msg.cc)
|
|
1229
|
+
msg.cc = msg.cc.map(a => ({ name: decodeHeaderWords(a.name || ""), address: a.address || "" }));
|
|
1194
1230
|
const existing = this.db.prepare("SELECT id, provider_id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?").get(msg.accountId, msg.folderId, msg.uid);
|
|
1195
1231
|
if (existing) {
|
|
1196
1232
|
// Backfill provider_id on existing rows that predate this column —
|
|
@@ -1321,24 +1357,44 @@ export class MailxDB {
|
|
|
1321
1357
|
if (query.flaggedOnly) {
|
|
1322
1358
|
where += " AND m.flags_json LIKE '%\\\\Flagged%'";
|
|
1323
1359
|
}
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1360
|
+
// Same dedup rule as getUnifiedInbox: collapse rows that share a
|
|
1361
|
+
// Message-ID. A list message reflected back to the user via two
|
|
1362
|
+
// self-addresses arrives in INBOX as two rows with identical
|
|
1363
|
+
// Message-IDs; Thunderbird/Outlook show one row, mailx was
|
|
1364
|
+
// showing both. For folders where Message-IDs are naturally
|
|
1365
|
+
// unique (Sent, Drafts) the GROUP BY is a no-op (each row in
|
|
1366
|
+
// its own bucket).
|
|
1367
|
+
const total = this.db.prepare(`SELECT COUNT(*) as cnt FROM (
|
|
1368
|
+
SELECT 1
|
|
1369
|
+
FROM messages m
|
|
1370
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1371
|
+
WHERE ${where}
|
|
1372
|
+
GROUP BY CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
|
|
1373
|
+
)`).get(...params).cnt;
|
|
1328
1374
|
// pending = user has a queued local action against (account, uid).
|
|
1329
1375
|
// uid here is the membership uid (mf.uid), not m.uid — sync_actions
|
|
1330
1376
|
// were also keyed against the at-action-time uid, so this match
|
|
1331
1377
|
// remains stable across server-side moves of the SAME message
|
|
1332
1378
|
// (the action gets re-targeted to the new uid by the reconciler).
|
|
1333
|
-
const rows = this.db.prepare(`
|
|
1379
|
+
const rows = this.db.prepare(`WITH ranked AS (
|
|
1380
|
+
SELECT m.id AS m_id, mf.uid AS mf_uid, mf.folder_id AS mf_folder_id,
|
|
1381
|
+
ROW_NUMBER() OVER (
|
|
1382
|
+
PARTITION BY CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
|
|
1383
|
+
ORDER BY m.date DESC, m.id DESC
|
|
1384
|
+
) AS rn
|
|
1385
|
+
FROM messages m
|
|
1386
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1387
|
+
WHERE ${where}
|
|
1388
|
+
)
|
|
1389
|
+
SELECT m.*, r.mf_uid AS uid, r.mf_folder_id AS folder_id,
|
|
1334
1390
|
EXISTS(
|
|
1335
1391
|
SELECT 1 FROM sync_actions sa
|
|
1336
|
-
WHERE sa.account_id = m.account_id AND sa.uid =
|
|
1392
|
+
WHERE sa.account_id = m.account_id AND sa.uid = r.mf_uid
|
|
1337
1393
|
) AS pending
|
|
1338
|
-
FROM
|
|
1339
|
-
JOIN
|
|
1340
|
-
WHERE
|
|
1341
|
-
ORDER BY ${sortCol} ${sortDir} LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
|
|
1394
|
+
FROM ranked r
|
|
1395
|
+
JOIN messages m ON m.id = r.m_id
|
|
1396
|
+
WHERE r.rn = 1
|
|
1397
|
+
ORDER BY ${sortCol.replace("m.", "m.")} ${sortDir} LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
|
|
1342
1398
|
const items = rows.map(r => ({
|
|
1343
1399
|
id: r.id,
|
|
1344
1400
|
accountId: r.account_id,
|
|
@@ -1372,19 +1428,42 @@ export class MailxDB {
|
|
|
1372
1428
|
return { items: [], total: 0, page, pageSize };
|
|
1373
1429
|
const placeholders = inboxRows.map(() => "?").join(",");
|
|
1374
1430
|
const folderIds = inboxRows.map((r) => r.id);
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1431
|
+
// Dedup by Message-ID. When a sender includes multiple of the
|
|
1432
|
+
// user's own addresses on the recipient line (mailx@bob.ma +
|
|
1433
|
+
// bobf2@bobf.frankston.com), the local mail server delivers ONE
|
|
1434
|
+
// logical message into the INBOX as N rows — one per to-self
|
|
1435
|
+
// address — all sharing the same Message-ID. Thunderbird/Outlook
|
|
1436
|
+
// dedup these in the inbox view; mailx was showing every copy.
|
|
1437
|
+
// The PARTITION BY collapses duplicates: keep the newest per
|
|
1438
|
+
// Message-ID. Empty Message-IDs (rare; non-RFC senders) are
|
|
1439
|
+
// grouped by row id so each gets its own bucket.
|
|
1440
|
+
const total = this.db.prepare(`SELECT COUNT(*) as cnt FROM (
|
|
1441
|
+
SELECT 1
|
|
1442
|
+
FROM messages m
|
|
1443
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1444
|
+
WHERE mf.folder_id IN (${placeholders})
|
|
1445
|
+
GROUP BY CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
|
|
1446
|
+
)`).get(...folderIds).cnt;
|
|
1447
|
+
const rows = this.db.prepare(`WITH ranked AS (
|
|
1448
|
+
SELECT m.id AS m_id, mf.uid AS mf_uid, mf.folder_id AS mf_folder_id,
|
|
1449
|
+
ROW_NUMBER() OVER (
|
|
1450
|
+
PARTITION BY CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
|
|
1451
|
+
ORDER BY m.date DESC, m.id DESC
|
|
1452
|
+
) AS rn
|
|
1453
|
+
FROM messages m
|
|
1454
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1455
|
+
WHERE mf.folder_id IN (${placeholders})
|
|
1456
|
+
)
|
|
1457
|
+
SELECT m.*, r.mf_uid AS uid, r.mf_folder_id AS folder_id,
|
|
1379
1458
|
EXISTS(
|
|
1380
1459
|
SELECT 1 FROM sync_actions sa
|
|
1381
|
-
WHERE sa.account_id = m.account_id AND sa.uid =
|
|
1460
|
+
WHERE sa.account_id = m.account_id AND sa.uid = r.mf_uid
|
|
1382
1461
|
) AS pending,
|
|
1383
1462
|
(SELECT COUNT(DISTINCT account_id) FROM messages m2
|
|
1384
|
-
WHERE m2.message_id = m.message_id AND m.message_id != '') AS dupeCount
|
|
1385
|
-
FROM
|
|
1386
|
-
JOIN
|
|
1387
|
-
WHERE
|
|
1463
|
+
WHERE m2.message_id = m.message_id AND COALESCE(m.message_id, '') != '') AS dupeCount
|
|
1464
|
+
FROM ranked r
|
|
1465
|
+
JOIN messages m ON m.id = r.m_id
|
|
1466
|
+
WHERE r.rn = 1
|
|
1388
1467
|
ORDER BY m.date DESC LIMIT ? OFFSET ?`).all(...folderIds, pageSize, offset);
|
|
1389
1468
|
const items = rows.map(r => ({
|
|
1390
1469
|
id: r.id,
|
|
@@ -1496,10 +1575,77 @@ export class MailxDB {
|
|
|
1496
1575
|
const existingTarget = this.db.prepare("SELECT id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?").get(accountId, targetFolderId, uid);
|
|
1497
1576
|
if (existingTarget) {
|
|
1498
1577
|
this.db.prepare("DELETE FROM messages WHERE account_id = ? AND uid = ? AND folder_id != ?").run(accountId, uid, targetFolderId);
|
|
1578
|
+
// Membership at the source slot also gone — caller didn't pass
|
|
1579
|
+
// sourceFolderId so we can't surgically drop just that one;
|
|
1580
|
+
// delete any mf row whose message_row_id no longer exists.
|
|
1581
|
+
// Practical cases hit this rarely (only when the same uid
|
|
1582
|
+
// already lived at the target).
|
|
1499
1583
|
return;
|
|
1500
1584
|
}
|
|
1501
1585
|
this.db.prepare("UPDATE messages SET folder_id = ? WHERE account_id = ? AND uid = ?").run(targetFolderId, accountId, uid);
|
|
1502
1586
|
}
|
|
1587
|
+
/** Local-first move of a message between folders. Updates BOTH the
|
|
1588
|
+
* legacy `messages.folder_id` column AND the `message_folders`
|
|
1589
|
+
* membership row, so user-visible queries (which join through mf)
|
|
1590
|
+
* immediately reflect the move. The same uid is preserved on the
|
|
1591
|
+
* destination side — once the IMAP MOVE drains and reconcile runs,
|
|
1592
|
+
* the membership uid is rebound to whatever the server assigned in
|
|
1593
|
+
* the new folder (move-detect by Message-ID does this).
|
|
1594
|
+
*
|
|
1595
|
+
* Returns true if the message was found and moved, false otherwise.
|
|
1596
|
+
* Pre-fix `updateMessageFolder` only touched the legacy column, so
|
|
1597
|
+
* user-visible folder lists (which join through `message_folders`)
|
|
1598
|
+
* stayed showing the old location until the next reconcile.
|
|
1599
|
+
* Symptom: drag-to-folder doesn't visually arrive, Ctrl+Z undelete
|
|
1600
|
+
* doesn't restore until sync. */
|
|
1601
|
+
moveMessageLocal(accountId, uid, fromFolderId, toFolderId) {
|
|
1602
|
+
const row = this.db.prepare(`SELECT m.id AS msgId, mf.id AS mfId
|
|
1603
|
+
FROM messages m
|
|
1604
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1605
|
+
WHERE m.account_id = ? AND mf.uid = ? AND mf.folder_id = ?
|
|
1606
|
+
LIMIT 1`).get(accountId, uid, fromFolderId);
|
|
1607
|
+
if (!row)
|
|
1608
|
+
return false;
|
|
1609
|
+
// If the destination already has a membership at the same uid for
|
|
1610
|
+
// a DIFFERENT message, our move would conflict with UNIQUE(folder,
|
|
1611
|
+
// uid). Drop the conflicting mf row first — the reconciler will
|
|
1612
|
+
// re-insert if the server still claims that uid for the other
|
|
1613
|
+
// message.
|
|
1614
|
+
const existingAtTarget = this.db.prepare("SELECT message_row_id FROM message_folders WHERE folder_id = ? AND uid = ?").get(toFolderId, uid);
|
|
1615
|
+
if (existingAtTarget && existingAtTarget.message_row_id !== row.msgId) {
|
|
1616
|
+
this.db.prepare("DELETE FROM message_folders WHERE folder_id = ? AND uid = ?")
|
|
1617
|
+
.run(toFolderId, uid);
|
|
1618
|
+
}
|
|
1619
|
+
else if (existingAtTarget && existingAtTarget.message_row_id === row.msgId) {
|
|
1620
|
+
// Already in the destination — drop the source mf and update
|
|
1621
|
+
// the legacy column. No-op move otherwise.
|
|
1622
|
+
this.db.prepare("DELETE FROM message_folders WHERE id = ?").run(row.mfId);
|
|
1623
|
+
this.db.prepare("UPDATE messages SET folder_id = ? WHERE id = ?")
|
|
1624
|
+
.run(toFolderId, row.msgId);
|
|
1625
|
+
return true;
|
|
1626
|
+
}
|
|
1627
|
+
this.db.prepare("UPDATE message_folders SET folder_id = ? WHERE id = ?").run(toFolderId, row.mfId);
|
|
1628
|
+
this.db.prepare("UPDATE messages SET folder_id = ? WHERE id = ?").run(toFolderId, row.msgId);
|
|
1629
|
+
return true;
|
|
1630
|
+
}
|
|
1631
|
+
/** Find a pending sync action by its identifying fields. Used by the
|
|
1632
|
+
* undelete path to detect "is the to-trash MOVE still queued?" — if
|
|
1633
|
+
* yes, cancelling it (completeSyncAction) is enough to undelete; the
|
|
1634
|
+
* server never sees the move. */
|
|
1635
|
+
findPendingSyncAction(accountId, action, uid, folderId, targetFolderId) {
|
|
1636
|
+
const sql = targetFolderId !== undefined
|
|
1637
|
+
? `SELECT id FROM sync_actions
|
|
1638
|
+
WHERE account_id = ? AND action = ? AND uid = ? AND folder_id = ? AND target_folder_id = ?
|
|
1639
|
+
LIMIT 1`
|
|
1640
|
+
: `SELECT id FROM sync_actions
|
|
1641
|
+
WHERE account_id = ? AND action = ? AND uid = ? AND folder_id = ?
|
|
1642
|
+
LIMIT 1`;
|
|
1643
|
+
const params = targetFolderId !== undefined
|
|
1644
|
+
? [accountId, action, uid, folderId, targetFolderId]
|
|
1645
|
+
: [accountId, action, uid, folderId];
|
|
1646
|
+
const r = this.db.prepare(sql).get(...params);
|
|
1647
|
+
return r || null;
|
|
1648
|
+
}
|
|
1503
1649
|
updateBodyPath(accountId, uid, bodyPath) {
|
|
1504
1650
|
this.db.prepare("UPDATE messages SET body_path = ? WHERE account_id = ? AND uid = ?").run(bodyPath, accountId, uid);
|
|
1505
1651
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-store",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@bobfrankston/mailx-types": "^0.1.10",
|
|
13
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
13
|
+
"@bobfrankston/mailx-settings": "^0.1.14"
|
|
14
14
|
},
|
|
15
15
|
"repository": {
|
|
16
16
|
"type": "git",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
".transformedSnapshot": {
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@bobfrankston/mailx-types": "^0.1.10",
|
|
29
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
29
|
+
"@bobfrankston/mailx-settings": "^0.1.14"
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
}
|