@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.
Files changed (3) hide show
  1. package/db.d.ts +22 -0
  2. package/db.js +165 -19
  3. 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
- const total = this.db.prepare(`SELECT COUNT(*) as cnt
1325
- FROM messages m
1326
- JOIN message_folders mf ON mf.message_row_id = m.id
1327
- WHERE ${where}`).get(...params).cnt;
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(`SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id,
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 = mf.uid
1392
+ WHERE sa.account_id = m.account_id AND sa.uid = r.mf_uid
1337
1393
  ) AS pending
1338
- FROM messages m
1339
- JOIN message_folders mf ON mf.message_row_id = m.id
1340
- WHERE ${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
- const total = this.db.prepare(`SELECT COUNT(*) as cnt
1376
- FROM message_folders mf
1377
- WHERE mf.folder_id IN (${placeholders})`).get(...folderIds).cnt;
1378
- const rows = this.db.prepare(`SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id,
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 = mf.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 messages m
1386
- JOIN message_folders mf ON mf.message_row_id = m.id
1387
- WHERE mf.folder_id IN (${placeholders})
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.15",
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"
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.13"
29
+ "@bobfrankston/mailx-settings": "^0.1.14"
30
30
  }
31
31
  }
32
32
  }