@bobfrankston/mailx-store 0.1.15 → 0.1.16
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 +129 -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
|
@@ -1321,24 +1321,44 @@ export class MailxDB {
|
|
|
1321
1321
|
if (query.flaggedOnly) {
|
|
1322
1322
|
where += " AND m.flags_json LIKE '%\\\\Flagged%'";
|
|
1323
1323
|
}
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1324
|
+
// Same dedup rule as getUnifiedInbox: collapse rows that share a
|
|
1325
|
+
// Message-ID. A list message reflected back to the user via two
|
|
1326
|
+
// self-addresses arrives in INBOX as two rows with identical
|
|
1327
|
+
// Message-IDs; Thunderbird/Outlook show one row, mailx was
|
|
1328
|
+
// showing both. For folders where Message-IDs are naturally
|
|
1329
|
+
// unique (Sent, Drafts) the GROUP BY is a no-op (each row in
|
|
1330
|
+
// its own bucket).
|
|
1331
|
+
const total = this.db.prepare(`SELECT COUNT(*) as cnt FROM (
|
|
1332
|
+
SELECT 1
|
|
1333
|
+
FROM messages m
|
|
1334
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1335
|
+
WHERE ${where}
|
|
1336
|
+
GROUP BY CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
|
|
1337
|
+
)`).get(...params).cnt;
|
|
1328
1338
|
// pending = user has a queued local action against (account, uid).
|
|
1329
1339
|
// uid here is the membership uid (mf.uid), not m.uid — sync_actions
|
|
1330
1340
|
// were also keyed against the at-action-time uid, so this match
|
|
1331
1341
|
// remains stable across server-side moves of the SAME message
|
|
1332
1342
|
// (the action gets re-targeted to the new uid by the reconciler).
|
|
1333
|
-
const rows = this.db.prepare(`
|
|
1343
|
+
const rows = this.db.prepare(`WITH ranked AS (
|
|
1344
|
+
SELECT m.id AS m_id, mf.uid AS mf_uid, mf.folder_id AS mf_folder_id,
|
|
1345
|
+
ROW_NUMBER() OVER (
|
|
1346
|
+
PARTITION BY CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
|
|
1347
|
+
ORDER BY m.date DESC, m.id DESC
|
|
1348
|
+
) AS rn
|
|
1349
|
+
FROM messages m
|
|
1350
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1351
|
+
WHERE ${where}
|
|
1352
|
+
)
|
|
1353
|
+
SELECT m.*, r.mf_uid AS uid, r.mf_folder_id AS folder_id,
|
|
1334
1354
|
EXISTS(
|
|
1335
1355
|
SELECT 1 FROM sync_actions sa
|
|
1336
|
-
WHERE sa.account_id = m.account_id AND sa.uid =
|
|
1356
|
+
WHERE sa.account_id = m.account_id AND sa.uid = r.mf_uid
|
|
1337
1357
|
) AS pending
|
|
1338
|
-
FROM
|
|
1339
|
-
JOIN
|
|
1340
|
-
WHERE
|
|
1341
|
-
ORDER BY ${sortCol} ${sortDir} LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
|
|
1358
|
+
FROM ranked r
|
|
1359
|
+
JOIN messages m ON m.id = r.m_id
|
|
1360
|
+
WHERE r.rn = 1
|
|
1361
|
+
ORDER BY ${sortCol.replace("m.", "m.")} ${sortDir} LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
|
|
1342
1362
|
const items = rows.map(r => ({
|
|
1343
1363
|
id: r.id,
|
|
1344
1364
|
accountId: r.account_id,
|
|
@@ -1372,19 +1392,42 @@ export class MailxDB {
|
|
|
1372
1392
|
return { items: [], total: 0, page, pageSize };
|
|
1373
1393
|
const placeholders = inboxRows.map(() => "?").join(",");
|
|
1374
1394
|
const folderIds = inboxRows.map((r) => r.id);
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1395
|
+
// Dedup by Message-ID. When a sender includes multiple of the
|
|
1396
|
+
// user's own addresses on the recipient line (mailx@bob.ma +
|
|
1397
|
+
// bobf2@bobf.frankston.com), the local mail server delivers ONE
|
|
1398
|
+
// logical message into the INBOX as N rows — one per to-self
|
|
1399
|
+
// address — all sharing the same Message-ID. Thunderbird/Outlook
|
|
1400
|
+
// dedup these in the inbox view; mailx was showing every copy.
|
|
1401
|
+
// The PARTITION BY collapses duplicates: keep the newest per
|
|
1402
|
+
// Message-ID. Empty Message-IDs (rare; non-RFC senders) are
|
|
1403
|
+
// grouped by row id so each gets its own bucket.
|
|
1404
|
+
const total = this.db.prepare(`SELECT COUNT(*) as cnt FROM (
|
|
1405
|
+
SELECT 1
|
|
1406
|
+
FROM messages m
|
|
1407
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1408
|
+
WHERE mf.folder_id IN (${placeholders})
|
|
1409
|
+
GROUP BY CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
|
|
1410
|
+
)`).get(...folderIds).cnt;
|
|
1411
|
+
const rows = this.db.prepare(`WITH ranked AS (
|
|
1412
|
+
SELECT m.id AS m_id, mf.uid AS mf_uid, mf.folder_id AS mf_folder_id,
|
|
1413
|
+
ROW_NUMBER() OVER (
|
|
1414
|
+
PARTITION BY CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
|
|
1415
|
+
ORDER BY m.date DESC, m.id DESC
|
|
1416
|
+
) AS rn
|
|
1417
|
+
FROM messages m
|
|
1418
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1419
|
+
WHERE mf.folder_id IN (${placeholders})
|
|
1420
|
+
)
|
|
1421
|
+
SELECT m.*, r.mf_uid AS uid, r.mf_folder_id AS folder_id,
|
|
1379
1422
|
EXISTS(
|
|
1380
1423
|
SELECT 1 FROM sync_actions sa
|
|
1381
|
-
WHERE sa.account_id = m.account_id AND sa.uid =
|
|
1424
|
+
WHERE sa.account_id = m.account_id AND sa.uid = r.mf_uid
|
|
1382
1425
|
) AS pending,
|
|
1383
1426
|
(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
|
|
1427
|
+
WHERE m2.message_id = m.message_id AND COALESCE(m.message_id, '') != '') AS dupeCount
|
|
1428
|
+
FROM ranked r
|
|
1429
|
+
JOIN messages m ON m.id = r.m_id
|
|
1430
|
+
WHERE r.rn = 1
|
|
1388
1431
|
ORDER BY m.date DESC LIMIT ? OFFSET ?`).all(...folderIds, pageSize, offset);
|
|
1389
1432
|
const items = rows.map(r => ({
|
|
1390
1433
|
id: r.id,
|
|
@@ -1496,10 +1539,77 @@ export class MailxDB {
|
|
|
1496
1539
|
const existingTarget = this.db.prepare("SELECT id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?").get(accountId, targetFolderId, uid);
|
|
1497
1540
|
if (existingTarget) {
|
|
1498
1541
|
this.db.prepare("DELETE FROM messages WHERE account_id = ? AND uid = ? AND folder_id != ?").run(accountId, uid, targetFolderId);
|
|
1542
|
+
// Membership at the source slot also gone — caller didn't pass
|
|
1543
|
+
// sourceFolderId so we can't surgically drop just that one;
|
|
1544
|
+
// delete any mf row whose message_row_id no longer exists.
|
|
1545
|
+
// Practical cases hit this rarely (only when the same uid
|
|
1546
|
+
// already lived at the target).
|
|
1499
1547
|
return;
|
|
1500
1548
|
}
|
|
1501
1549
|
this.db.prepare("UPDATE messages SET folder_id = ? WHERE account_id = ? AND uid = ?").run(targetFolderId, accountId, uid);
|
|
1502
1550
|
}
|
|
1551
|
+
/** Local-first move of a message between folders. Updates BOTH the
|
|
1552
|
+
* legacy `messages.folder_id` column AND the `message_folders`
|
|
1553
|
+
* membership row, so user-visible queries (which join through mf)
|
|
1554
|
+
* immediately reflect the move. The same uid is preserved on the
|
|
1555
|
+
* destination side — once the IMAP MOVE drains and reconcile runs,
|
|
1556
|
+
* the membership uid is rebound to whatever the server assigned in
|
|
1557
|
+
* the new folder (move-detect by Message-ID does this).
|
|
1558
|
+
*
|
|
1559
|
+
* Returns true if the message was found and moved, false otherwise.
|
|
1560
|
+
* Pre-fix `updateMessageFolder` only touched the legacy column, so
|
|
1561
|
+
* user-visible folder lists (which join through `message_folders`)
|
|
1562
|
+
* stayed showing the old location until the next reconcile.
|
|
1563
|
+
* Symptom: drag-to-folder doesn't visually arrive, Ctrl+Z undelete
|
|
1564
|
+
* doesn't restore until sync. */
|
|
1565
|
+
moveMessageLocal(accountId, uid, fromFolderId, toFolderId) {
|
|
1566
|
+
const row = this.db.prepare(`SELECT m.id AS msgId, mf.id AS mfId
|
|
1567
|
+
FROM messages m
|
|
1568
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1569
|
+
WHERE m.account_id = ? AND mf.uid = ? AND mf.folder_id = ?
|
|
1570
|
+
LIMIT 1`).get(accountId, uid, fromFolderId);
|
|
1571
|
+
if (!row)
|
|
1572
|
+
return false;
|
|
1573
|
+
// If the destination already has a membership at the same uid for
|
|
1574
|
+
// a DIFFERENT message, our move would conflict with UNIQUE(folder,
|
|
1575
|
+
// uid). Drop the conflicting mf row first — the reconciler will
|
|
1576
|
+
// re-insert if the server still claims that uid for the other
|
|
1577
|
+
// message.
|
|
1578
|
+
const existingAtTarget = this.db.prepare("SELECT message_row_id FROM message_folders WHERE folder_id = ? AND uid = ?").get(toFolderId, uid);
|
|
1579
|
+
if (existingAtTarget && existingAtTarget.message_row_id !== row.msgId) {
|
|
1580
|
+
this.db.prepare("DELETE FROM message_folders WHERE folder_id = ? AND uid = ?")
|
|
1581
|
+
.run(toFolderId, uid);
|
|
1582
|
+
}
|
|
1583
|
+
else if (existingAtTarget && existingAtTarget.message_row_id === row.msgId) {
|
|
1584
|
+
// Already in the destination — drop the source mf and update
|
|
1585
|
+
// the legacy column. No-op move otherwise.
|
|
1586
|
+
this.db.prepare("DELETE FROM message_folders WHERE id = ?").run(row.mfId);
|
|
1587
|
+
this.db.prepare("UPDATE messages SET folder_id = ? WHERE id = ?")
|
|
1588
|
+
.run(toFolderId, row.msgId);
|
|
1589
|
+
return true;
|
|
1590
|
+
}
|
|
1591
|
+
this.db.prepare("UPDATE message_folders SET folder_id = ? WHERE id = ?").run(toFolderId, row.mfId);
|
|
1592
|
+
this.db.prepare("UPDATE messages SET folder_id = ? WHERE id = ?").run(toFolderId, row.msgId);
|
|
1593
|
+
return true;
|
|
1594
|
+
}
|
|
1595
|
+
/** Find a pending sync action by its identifying fields. Used by the
|
|
1596
|
+
* undelete path to detect "is the to-trash MOVE still queued?" — if
|
|
1597
|
+
* yes, cancelling it (completeSyncAction) is enough to undelete; the
|
|
1598
|
+
* server never sees the move. */
|
|
1599
|
+
findPendingSyncAction(accountId, action, uid, folderId, targetFolderId) {
|
|
1600
|
+
const sql = targetFolderId !== undefined
|
|
1601
|
+
? `SELECT id FROM sync_actions
|
|
1602
|
+
WHERE account_id = ? AND action = ? AND uid = ? AND folder_id = ? AND target_folder_id = ?
|
|
1603
|
+
LIMIT 1`
|
|
1604
|
+
: `SELECT id FROM sync_actions
|
|
1605
|
+
WHERE account_id = ? AND action = ? AND uid = ? AND folder_id = ?
|
|
1606
|
+
LIMIT 1`;
|
|
1607
|
+
const params = targetFolderId !== undefined
|
|
1608
|
+
? [accountId, action, uid, folderId, targetFolderId]
|
|
1609
|
+
: [accountId, action, uid, folderId];
|
|
1610
|
+
const r = this.db.prepare(sql).get(...params);
|
|
1611
|
+
return r || null;
|
|
1612
|
+
}
|
|
1503
1613
|
updateBodyPath(accountId, uid, bodyPath) {
|
|
1504
1614
|
this.db.prepare("UPDATE messages SET body_path = ? WHERE account_id = ? AND uid = ?").run(bodyPath, accountId, uid);
|
|
1505
1615
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-store",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
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
|
}
|