@bobfrankston/mailx-store 0.1.14 → 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.
Files changed (3) hide show
  1. package/db.d.ts +23 -1
  2. package/db.js +144 -23
  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): {
@@ -436,7 +458,7 @@ export declare class MailxDB {
436
458
  * contact can have multiple email addresses, each is its own row). */
437
459
  deleteContactByGoogleId(googleId: string): number;
438
460
  /** Full-text search across all messages. Supports qualifiers: from:, to:, subject: */
439
- searchMessages(query: string, page?: number, pageSize?: number, accountId?: string, folderId?: number): PagedResult<MessageEnvelope>;
461
+ searchMessages(query: string, page?: number, pageSize?: number, accountId?: string, folderId?: number, includeTrashSpam?: boolean): PagedResult<MessageEnvelope>;
440
462
  /** Rebuild FTS index from existing messages */
441
463
  rebuildSearchIndex(): number;
442
464
  /** Queue a local action for later sync to IMAP */
package/db.js CHANGED
@@ -488,12 +488,12 @@ export class MailxDB {
488
488
  actual = this.db.prepare(`PRAGMA table_info(${table})`).all();
489
489
  }
490
490
  catch (e) {
491
- throw new Error(`[mailx-store] schema check failed for "${table}": ${e.message}. Run 'mailx -rebuild' to rebuild the local store.`);
491
+ throw new Error(`[mailx-store] schema check failed for "${table}": ${e.message}. Run 'rmfmail -rebuild' to rebuild the local store.`);
492
492
  }
493
493
  const names = new Set(actual.map(r => r.name));
494
494
  const missing = cols.filter(c => !names.has(c));
495
495
  if (missing.length > 0) {
496
- throw new Error(`[mailx-store] table "${table}" is missing columns [${missing.join(", ")}] — schema migration did not complete. Run 'mailx -rebuild' to rebuild the local store.`);
496
+ throw new Error(`[mailx-store] table "${table}" is missing columns [${missing.join(", ")}] — schema migration did not complete. Run 'rmfmail -rebuild' to rebuild the local store.`);
497
497
  }
498
498
  }
499
499
  this.runOneShotJunkContactPurge();
@@ -1321,24 +1321,44 @@ export class MailxDB {
1321
1321
  if (query.flaggedOnly) {
1322
1322
  where += " AND m.flags_json LIKE '%\\\\Flagged%'";
1323
1323
  }
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;
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(`SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id,
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 = mf.uid
1356
+ WHERE sa.account_id = m.account_id AND sa.uid = r.mf_uid
1337
1357
  ) 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);
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
- 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,
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 = mf.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 messages m
1386
- JOIN message_folders mf ON mf.message_row_id = m.id
1387
- WHERE mf.folder_id IN (${placeholders})
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
  }
@@ -2051,7 +2161,7 @@ export class MailxDB {
2051
2161
  }
2052
2162
  // ── Search ──
2053
2163
  /** Full-text search across all messages. Supports qualifiers: from:, to:, subject: */
2054
- searchMessages(query, page = 1, pageSize = 50, accountId, folderId) {
2164
+ searchMessages(query, page = 1, pageSize = 50, accountId, folderId, includeTrashSpam = false) {
2055
2165
  query = (query || "").trim();
2056
2166
  // Parse qualifiers (C45: extended set — date:, has:, is:, folder:).
2057
2167
  let ftsQuery = "";
@@ -2179,11 +2289,22 @@ export class MailxDB {
2179
2289
  scopeWhere = " AND m.account_id = ?";
2180
2290
  scopeParams.push(accountId);
2181
2291
  }
2292
+ // Global / account-wide search excludes trash + junk unless the
2293
+ // user opted in. A user typing "invoice" in the search box almost
2294
+ // never wants the deleted/spam copy that's already filtered out
2295
+ // of every other view. When the user IS explicitly scoped to
2296
+ // trash/junk via folderId, this guard is skipped.
2297
+ if (!includeTrashSpam && !folderId) {
2298
+ scopeWhere += " AND (f.special_use IS NULL OR f.special_use NOT IN ('trash','junk'))";
2299
+ }
2182
2300
  if (extraWhere.length > 0) {
2183
2301
  scopeWhere += " AND " + extraWhere.join(" AND ");
2184
2302
  scopeParams.push(...extraParams);
2185
2303
  }
2186
- const countRow = this.db.prepare(`SELECT COUNT(*) as cnt FROM messages m JOIN messages_fts fts ON m.id = fts.rowid WHERE messages_fts MATCH ?${scopeWhere}`).get(ftsQuery, ...scopeParams);
2304
+ const countRow = this.db.prepare(`SELECT COUNT(*) as cnt FROM messages m
2305
+ JOIN messages_fts fts ON m.id = fts.rowid
2306
+ LEFT JOIN folders f ON f.id = m.folder_id AND f.account_id = m.account_id
2307
+ WHERE messages_fts MATCH ?${scopeWhere}`).get(ftsQuery, ...scopeParams);
2187
2308
  const total = countRow?.cnt || 0;
2188
2309
  const rows = this.db.prepare(`SELECT m.*, f.name AS folder_name FROM messages m
2189
2310
  JOIN messages_fts fts ON m.id = fts.rowid
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.14",
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"
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
  }