@remit/mailbox-service 0.0.1

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 (124) hide show
  1. package/README.md +147 -0
  2. package/package.json +65 -0
  3. package/src/account-credentials.ts +128 -0
  4. package/src/adversarial-mime.e2e.test.ts +311 -0
  5. package/src/attribute-mapper.test.ts +78 -0
  6. package/src/attribute-mapper.ts +147 -0
  7. package/src/body-part-mapper.property.test.ts +354 -0
  8. package/src/body-part-mapper.ts +360 -0
  9. package/src/body-sync-queue.test.ts +124 -0
  10. package/src/body-sync-queue.ts +131 -0
  11. package/src/body-sync-terminal.test.ts +265 -0
  12. package/src/body-sync-terminal.ts +172 -0
  13. package/src/body-sync.ts +1202 -0
  14. package/src/connection-factory.ts +102 -0
  15. package/src/connection-test.ts +208 -0
  16. package/src/credentials.test.ts +181 -0
  17. package/src/filters/match.test.ts +181 -0
  18. package/src/filters/match.ts +143 -0
  19. package/src/filters/pipeline.ts +243 -0
  20. package/src/flag-push-terminal.test.ts +156 -0
  21. package/src/flag-push-terminal.ts +106 -0
  22. package/src/flag-push.test.ts +408 -0
  23. package/src/flag-push.ts +198 -0
  24. package/src/flag-queue.ts +500 -0
  25. package/src/heuristics/classifyByHeaders.test.ts +578 -0
  26. package/src/heuristics/classifyByHeaders.ts +369 -0
  27. package/src/heuristics/classifyPlacement.ts +96 -0
  28. package/src/heuristics/socialDomains.ts +23 -0
  29. package/src/heuristics/transactionalDomains.ts +26 -0
  30. package/src/imapflow-connect-retry.test.ts +108 -0
  31. package/src/imapflow-connection.e2e.test.ts +355 -0
  32. package/src/imapflow-connection.integ.test.ts +327 -0
  33. package/src/imapflow-connection.test.ts +161 -0
  34. package/src/imapflow-connection.ts +1374 -0
  35. package/src/index.ts +214 -0
  36. package/src/mailbox-cursor-rebuild.test.ts +171 -0
  37. package/src/mailbox-cursor-rebuild.ts +146 -0
  38. package/src/mailbox-cursor.test.ts +249 -0
  39. package/src/mailbox-cursor.ts +177 -0
  40. package/src/mailbox-management.test.ts +103 -0
  41. package/src/mailbox-management.ts +235 -0
  42. package/src/mailbox-queue.ts +258 -0
  43. package/src/mailbox-sync.test.ts +153 -0
  44. package/src/mailbox-sync.ts +590 -0
  45. package/src/message-move.ts +825 -0
  46. package/src/message-parser.ts +26 -0
  47. package/src/message-sync.ts +1021 -0
  48. package/src/mime-walker.test.ts +311 -0
  49. package/src/mime-walker.ts +305 -0
  50. package/src/outbox-queue.ts +294 -0
  51. package/src/pass-through-unit-of-work.ts +17 -0
  52. package/src/placement-move-terminal.test.ts +179 -0
  53. package/src/placement-move-terminal.ts +104 -0
  54. package/src/placement-move.ts +224 -0
  55. package/src/snippet.test.ts +213 -0
  56. package/src/snippet.ts +152 -0
  57. package/src/stale-message-reconcile.test.ts +82 -0
  58. package/src/stale-message-reconcile.ts +55 -0
  59. package/src/test-helpers/isolated-mailbox.ts +44 -0
  60. package/src/test-helpers/mailfuzz-connection.ts +27 -0
  61. package/src/test-helpers/mime-tree-generator.ts +461 -0
  62. package/src/text/normalizer.test.ts +223 -0
  63. package/src/text/normalizer.ts +140 -0
  64. package/src/types/mailparser-augment.d.ts +15 -0
  65. package/src/types/natural-porter-stemmer.d.ts +50 -0
  66. package/src/types/stopword.d.ts +69 -0
  67. package/src/types.ts +349 -0
  68. package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
  69. package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
  70. package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
  71. package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
  72. package/test/fixtures/mime/02-flat-text-html.eml +10 -0
  73. package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
  74. package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
  75. package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
  76. package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
  77. package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
  78. package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
  79. package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
  80. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
  81. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
  82. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
  83. package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
  84. package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
  85. package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
  86. package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
  87. package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
  88. package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
  89. package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
  90. package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
  91. package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
  92. package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
  93. package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
  94. package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
  95. package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
  96. package/test/fixtures/mime/10-calendar-invite.eml +33 -0
  97. package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
  98. package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
  99. package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
  100. package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
  101. package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
  102. package/test/fixtures/mime/12-base64-binary.eml +20 -0
  103. package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
  104. package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
  105. package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
  106. package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
  107. package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
  108. package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
  109. package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
  110. package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
  111. package/test/fixtures/mime/15-empty-text-part.eml +18 -0
  112. package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
  113. package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
  114. package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
  115. package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
  124. package/tsconfig.json +8 -0
package/src/index.ts ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * remit-mailbox-service
3
+ *
4
+ * IMAP mailbox synchronization service for Remit
5
+ */
6
+
7
+ export {
8
+ type AccountCredentialsDeps,
9
+ encryptRefreshToken,
10
+ resolveConnectionCredentials,
11
+ } from "./account-credentials.js";
12
+ export {
13
+ hasChildren,
14
+ isNoSelect,
15
+ type ParsedAttributes,
16
+ parseImapAttributes,
17
+ } from "./attribute-mapper.js";
18
+ export {
19
+ type BodySyncLogger,
20
+ BodySyncService,
21
+ type ConnectionGetter,
22
+ extractPrimaryFromEmail,
23
+ type FetchBodyResult,
24
+ type PlacementConfig,
25
+ type SyncBodiesResult,
26
+ toParsedBody,
27
+ } from "./body-sync.js";
28
+ export {
29
+ type BodySyncQueueConfig,
30
+ type BodySyncQueueLogger,
31
+ BodySyncQueueService,
32
+ type RequestBodySyncInput,
33
+ } from "./body-sync-queue.js";
34
+ export {
35
+ isMessageBodySyncBroken,
36
+ type ResolveExhaustedBodySyncDeps,
37
+ type ResolveExhaustedBodySyncInput,
38
+ type ResolveExhaustedBodySyncResult,
39
+ resolveExhaustedBodySyncFailures,
40
+ } from "./body-sync-terminal.js";
41
+ // Connection factory
42
+ export {
43
+ createConnection,
44
+ createConnectionFromAccount,
45
+ createConnectionWithCredentials,
46
+ createManagedConnectionFactory,
47
+ type ManagedConnectionFactory,
48
+ } from "./connection-factory.js";
49
+ // Connection testing
50
+ export {
51
+ type ImapTestConfig,
52
+ type SmtpTestConfig,
53
+ type TestResult,
54
+ testImapConnection,
55
+ testSmtpConnection,
56
+ } from "./connection-test.js";
57
+ export {
58
+ buildMatchText,
59
+ clauseMatches,
60
+ cosineSimilarity,
61
+ DEFAULT_SEMANTIC_MATCH_THRESHOLD,
62
+ type FilterMessage,
63
+ literalClausesMatch,
64
+ NO_ACTION,
65
+ selectMoveWinner,
66
+ } from "./filters/match.js";
67
+ export {
68
+ type FilterConfig,
69
+ type FilterDecision,
70
+ type FilterLogger,
71
+ FilterPipeline,
72
+ type MessageEmbedder,
73
+ } from "./filters/pipeline.js";
74
+ export {
75
+ type FlagPushConfig,
76
+ type FlagPushEvent,
77
+ type FlagPushLogger,
78
+ type FlagPushOperationValue,
79
+ FlagPushService,
80
+ } from "./flag-push.js";
81
+ export {
82
+ type FlagPushTerminalOutcome,
83
+ type ResolveExhaustedFlagPushDeps,
84
+ type ResolveExhaustedFlagPushInput,
85
+ type ResolveExhaustedFlagPushResult,
86
+ resolveExhaustedFlagPushFailure,
87
+ } from "./flag-push-terminal.js";
88
+ export {
89
+ type FlagQueueConfig,
90
+ type FlagQueueLogger,
91
+ FlagQueueService,
92
+ type UpdateFlagsInput,
93
+ type UpdateFlagsResult,
94
+ } from "./flag-queue.js";
95
+ export {
96
+ classifyByHeaders,
97
+ extractAuthenticity,
98
+ extractAuthResult,
99
+ extractHasListUnsubscribe,
100
+ extractProviderSpam,
101
+ type MessageAuthenticity,
102
+ type MessageAuthResult,
103
+ type MessageProviderSpam,
104
+ } from "./heuristics/classifyByHeaders.js";
105
+ export {
106
+ classifyPlacement,
107
+ type FolderPlacement,
108
+ type PlacementAction,
109
+ type PlacementVerdict,
110
+ } from "./heuristics/classifyPlacement.js";
111
+ export { SOCIAL_DOMAINS } from "./heuristics/socialDomains.js";
112
+ export { TRANSACTIONAL_DOMAINS } from "./heuristics/transactionalDomains.js";
113
+ // IMAP connection (ImapFlow-based)
114
+ export {
115
+ createImapFlowConnectionFromAccount,
116
+ createImapFlowConnectionWithCredentials,
117
+ ImapFlowConnection,
118
+ } from "./imapflow-connection.js";
119
+ export {
120
+ guardConnectionCursor,
121
+ guardMailboxCursor,
122
+ isCursorRebuildNeeded,
123
+ type MailboxCursorCheck,
124
+ type MailboxCursorGuardDeps,
125
+ MailboxCursorPausedError,
126
+ } from "./mailbox-cursor.js";
127
+ export {
128
+ type CursorRebuildMatch,
129
+ type CursorRebuildMatchResult,
130
+ type CursorRebuildRow,
131
+ type CursorRebuildSnapshot,
132
+ matchCursorRebuild,
133
+ } from "./mailbox-cursor-rebuild.js";
134
+ export {
135
+ type CreateMailboxInput,
136
+ type DeleteMailboxInput,
137
+ type MailboxManagementLogger,
138
+ MailboxManagementService,
139
+ type MailboxManagementSyncResult,
140
+ parseMailboxPath,
141
+ type RenameMailboxInput,
142
+ validateMailboxOperation,
143
+ validateMailboxPath,
144
+ } from "./mailbox-management.js";
145
+ export {
146
+ type CreateMailboxQueueInput,
147
+ type MailboxQueueConfig,
148
+ type MailboxQueueLogger,
149
+ MailboxQueueService,
150
+ } from "./mailbox-queue.js";
151
+ export {
152
+ MailboxSyncService,
153
+ type SyncAccountInfo,
154
+ } from "./mailbox-sync.js";
155
+ export {
156
+ type DeleteOptions,
157
+ type MessageMoveConfig,
158
+ type MessageMoveLogger,
159
+ MessageMoveService,
160
+ } from "./message-move.js";
161
+ export {
162
+ type ParsedMessageContent,
163
+ parseMessageContent,
164
+ } from "./message-parser.js";
165
+ export {
166
+ type ImapConnectionFactory,
167
+ MessageSyncService,
168
+ type SyncedMessage,
169
+ type SyncMessagesResult,
170
+ } from "./message-sync.js";
171
+ export {
172
+ type CreateDraftInput,
173
+ type OutboxQueueConfig,
174
+ type OutboxQueueLogger,
175
+ OutboxQueueService,
176
+ type UpdateDraftInput,
177
+ } from "./outbox-queue.js";
178
+ export {
179
+ type PlacementMoveConfig,
180
+ type PlacementMoveLogger,
181
+ type PlacementMovePushEvent,
182
+ PlacementMoveService,
183
+ } from "./placement-move.js";
184
+ export {
185
+ type PlacementMoveTerminalOutcome,
186
+ type ResolveExhaustedPlacementMoveDeps,
187
+ type ResolveExhaustedPlacementMoveInput,
188
+ type ResolveExhaustedPlacementMoveResult,
189
+ resolveExhaustedPlacementMoveFailure,
190
+ } from "./placement-move-terminal.js";
191
+ export {
192
+ extractSnippetFromEmail,
193
+ generateSnippet,
194
+ normalizeSubject,
195
+ removeQuotedContent,
196
+ } from "./snippet.js";
197
+ export {
198
+ reconcileStaleMessage,
199
+ type StaleMessageReconcileDeps,
200
+ type StaleMessageReconcileResult,
201
+ } from "./stale-message-reconcile.js";
202
+ export type {
203
+ FlatMailboxInfo,
204
+ IImapConnection,
205
+ ImapBoxStatus,
206
+ ImapConnectionConfig,
207
+ ImapConnectionState,
208
+ ImapNamespace,
209
+ ImapNamespaces,
210
+ MailboxSyncResult,
211
+ MailConnectionErrorKind,
212
+ MailCredentials,
213
+ } from "./types.js";
214
+ export { MailConnectionError } from "./types.js";
@@ -0,0 +1,171 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ type CursorRebuildRow,
5
+ type CursorRebuildSnapshot,
6
+ matchCursorRebuild,
7
+ } from "./mailbox-cursor-rebuild.js";
8
+
9
+ const row = (over: Partial<CursorRebuildRow>): CursorRebuildRow => ({
10
+ messageId: "msg-1",
11
+ messageIdHeader: "<a@example.com>",
12
+ internalDate: 1_700_000_000_000,
13
+ uid: 10,
14
+ ...over,
15
+ });
16
+
17
+ const snapshot = (
18
+ over: Partial<CursorRebuildSnapshot>,
19
+ ): CursorRebuildSnapshot => ({
20
+ uid: 10,
21
+ messageId: "<a@example.com>",
22
+ internalDate: 1_700_000_000_000,
23
+ ...over,
24
+ });
25
+
26
+ describe("matchCursorRebuild", () => {
27
+ it("matches by Message-ID and rewrites the UID mapping when the UID changed", () => {
28
+ const existingRows = [row({ messageId: "msg-1", uid: 10 })];
29
+ const serverSnapshots = [snapshot({ uid: 55 })];
30
+
31
+ const result = matchCursorRebuild(serverSnapshots, existingRows);
32
+
33
+ assert.deepEqual(result.matched, [
34
+ { messageId: "msg-1", oldUid: 10, newUid: 55 },
35
+ ]);
36
+ assert.deepEqual(result.newUids, []);
37
+ assert.deepEqual(result.staleMessageIds, []);
38
+ });
39
+
40
+ it("does not emit a rewrite when the matched UID is unchanged (frugal — no-op write)", () => {
41
+ const existingRows = [row({ messageId: "msg-1", uid: 10 })];
42
+ const serverSnapshots = [snapshot({ uid: 10 })];
43
+
44
+ const result = matchCursorRebuild(serverSnapshots, existingRows);
45
+
46
+ assert.deepEqual(result.matched, []);
47
+ assert.deepEqual(result.staleMessageIds, []);
48
+ });
49
+
50
+ it("classifies a server message with no matching row as new — normal new-message sync", () => {
51
+ const existingRows: CursorRebuildRow[] = [];
52
+ const serverSnapshots = [
53
+ snapshot({ uid: 99, messageId: "<never-seen@example.com>" }),
54
+ ];
55
+
56
+ const result = matchCursorRebuild(serverSnapshots, existingRows);
57
+
58
+ assert.deepEqual(result.matched, []);
59
+ assert.deepEqual(result.newUids, [99]);
60
+ assert.deepEqual(result.staleMessageIds, []);
61
+ });
62
+
63
+ it("classifies a row with no matching server message as stale — expunged, reconcile", () => {
64
+ const existingRows = [
65
+ row({ messageId: "msg-gone", messageIdHeader: "<gone@example.com>" }),
66
+ ];
67
+ const serverSnapshots: CursorRebuildSnapshot[] = [];
68
+
69
+ const result = matchCursorRebuild(serverSnapshots, existingRows);
70
+
71
+ assert.deepEqual(result.matched, []);
72
+ assert.deepEqual(result.newUids, []);
73
+ assert.deepEqual(result.staleMessageIds, ["msg-gone"]);
74
+ });
75
+
76
+ it("handles a mixed batch: one match, one new, one stale", () => {
77
+ const existingRows = [
78
+ row({
79
+ messageId: "msg-keep",
80
+ messageIdHeader: "<keep@example.com>",
81
+ uid: 5,
82
+ }),
83
+ row({
84
+ messageId: "msg-gone",
85
+ messageIdHeader: "<gone@example.com>",
86
+ uid: 6,
87
+ }),
88
+ ];
89
+ const serverSnapshots = [
90
+ snapshot({ uid: 105, messageId: "<keep@example.com>" }),
91
+ snapshot({ uid: 200, messageId: "<new@example.com>" }),
92
+ ];
93
+
94
+ const result = matchCursorRebuild(serverSnapshots, existingRows);
95
+
96
+ assert.deepEqual(result.matched, [
97
+ { messageId: "msg-keep", oldUid: 5, newUid: 105 },
98
+ ]);
99
+ assert.deepEqual(result.newUids, [200]);
100
+ assert.deepEqual(result.staleMessageIds, ["msg-gone"]);
101
+ });
102
+
103
+ it("falls back to an exact INTERNALDATE match for headerless messages on both sides", () => {
104
+ const existingRows = [
105
+ row({
106
+ messageId: "msg-headerless",
107
+ messageIdHeader: "",
108
+ internalDate: 42,
109
+ uid: 3,
110
+ }),
111
+ ];
112
+ const serverSnapshots = [
113
+ snapshot({ uid: 77, messageId: "", internalDate: 42 }),
114
+ ];
115
+
116
+ const result = matchCursorRebuild(serverSnapshots, existingRows);
117
+
118
+ assert.deepEqual(result.matched, [
119
+ { messageId: "msg-headerless", oldUid: 3, newUid: 77 },
120
+ ]);
121
+ assert.deepEqual(result.staleMessageIds, []);
122
+ });
123
+
124
+ it("picks the closest INTERNALDATE among multiple rows sharing one Message-ID (legitimate resends)", () => {
125
+ const existingRows = [
126
+ row({ messageId: "msg-early", internalDate: 1000, uid: 1 }),
127
+ row({ messageId: "msg-late", internalDate: 5000, uid: 2 }),
128
+ ];
129
+ const serverSnapshots = [snapshot({ uid: 50, internalDate: 4900 })];
130
+
131
+ const result = matchCursorRebuild(serverSnapshots, existingRows);
132
+
133
+ assert.deepEqual(result.matched, [
134
+ { messageId: "msg-late", oldUid: 2, newUid: 50 },
135
+ ]);
136
+ assert.deepEqual(result.staleMessageIds, ["msg-early"]);
137
+ });
138
+
139
+ it("carries the row's threadMessage ref through to the match, so the caller can rewrite ThreadMessage.uid alongside Message.uid", () => {
140
+ const threadMessage = {
141
+ accountConfigId: "acfg-1",
142
+ threadMessageId: "tm-1",
143
+ sentDate: 1_700_000_000_000,
144
+ mailboxId: "mbx-1",
145
+ isRead: true,
146
+ isDeleted: false,
147
+ hasStars: false,
148
+ hasAttachment: false,
149
+ };
150
+ const existingRows = [row({ messageId: "msg-1", uid: 10, threadMessage })];
151
+ const serverSnapshots = [snapshot({ uid: 99 })];
152
+
153
+ const result = matchCursorRebuild(serverSnapshots, existingRows);
154
+
155
+ assert.deepEqual(result.matched, [
156
+ { messageId: "msg-1", oldUid: 10, newUid: 99, threadMessage },
157
+ ]);
158
+ });
159
+
160
+ it("omits threadMessage from the match when the row carries none (test fixtures that don't need it)", () => {
161
+ const existingRows = [row({ messageId: "msg-1", uid: 10 })];
162
+ const serverSnapshots = [snapshot({ uid: 99 })];
163
+
164
+ const result = matchCursorRebuild(serverSnapshots, existingRows);
165
+
166
+ assert.deepEqual(result.matched, [
167
+ { messageId: "msg-1", oldUid: 10, newUid: 99 },
168
+ ]);
169
+ assert.equal("threadMessage" in result.matched[0], false);
170
+ });
171
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Pure matching logic for the UIDVALIDITY cursor rebuild (issue #1272).
3
+ *
4
+ * Message identity is the Message-ID header; UIDs are cursor positions on
5
+ * the server's axis. When UIDVALIDITY changes, the axis is gone but the
6
+ * data isn't — this reconciles a fresh envelope-level snapshot (UID +
7
+ * Message-ID + INTERNALDATE, no body fetch) against the rows already stored
8
+ * for the mailbox, by identity rather than by position.
9
+ */
10
+
11
+ /** One server message from the cheap envelope-only rebuild FETCH. */
12
+ export interface CursorRebuildSnapshot {
13
+ uid: number;
14
+ /** Raw RFC 822 Message-ID header; empty string when absent/unparseable. */
15
+ messageId: string;
16
+ /** INTERNALDATE in epoch milliseconds. */
17
+ internalDate: number;
18
+ }
19
+
20
+ /**
21
+ * ThreadMessage identity + every composite-key attribute ElectroDB needs to
22
+ * run a conditional update against the row (see `buildThreadMessageMoveUpdate`
23
+ * in `message-move.ts` for the same pattern on a normal move). Carried
24
+ * alongside a {@link CursorRebuildRow} purely so a match can rewrite
25
+ * ThreadMessage.uid together with Message.uid — matching itself never reads
26
+ * these fields.
27
+ */
28
+ export interface CursorRebuildThreadMessageRef {
29
+ accountConfigId: string;
30
+ threadMessageId: string;
31
+ sentDate: number;
32
+ mailboxId: string;
33
+ isRead: boolean;
34
+ isDeleted: boolean;
35
+ hasStars: boolean;
36
+ hasAttachment: boolean;
37
+ }
38
+
39
+ /** One existing row for the mailbox, as needed to match it against a snapshot. */
40
+ export interface CursorRebuildRow {
41
+ /** Our internal messageId (the Message row's primary key). */
42
+ messageId: string;
43
+ /** Raw RFC 822 Message-ID header the row was created with. */
44
+ messageIdHeader: string;
45
+ /** INTERNALDATE in epoch milliseconds. */
46
+ internalDate: number;
47
+ /** The row's currently-stored UID (on the old, invalid axis). */
48
+ uid: number;
49
+ /** Present when the caller wants a match to also rewrite ThreadMessage.uid. */
50
+ threadMessage?: CursorRebuildThreadMessageRef;
51
+ }
52
+
53
+ export interface CursorRebuildMatch {
54
+ messageId: string;
55
+ oldUid: number;
56
+ newUid: number;
57
+ /** Carried through from the matched row so the caller can rewrite
58
+ * ThreadMessage.uid alongside Message.uid without a second lookup. */
59
+ threadMessage?: CursorRebuildThreadMessageRef;
60
+ }
61
+
62
+ export interface CursorRebuildMatchResult {
63
+ /** Rows whose Message-ID matched a server message under a different UID — rewrite in place. */
64
+ matched: CursorRebuildMatch[];
65
+ /** Server UIDs with no matching row — run through normal new-message sync. */
66
+ newUids: number[];
67
+ /** Rows with no matching server message — expunged; reconcile (#1283). */
68
+ staleMessageIds: string[];
69
+ }
70
+
71
+ const pickClosestByInternalDate = (
72
+ snapshot: CursorRebuildSnapshot,
73
+ candidates: CursorRebuildRow[],
74
+ consumed: Set<string>,
75
+ ): CursorRebuildRow | undefined => {
76
+ const available = candidates.filter((row) => !consumed.has(row.messageId));
77
+ if (available.length === 0) return undefined;
78
+ if (available.length === 1) return available[0];
79
+ return available.reduce((best, row) =>
80
+ Math.abs(row.internalDate - snapshot.internalDate) <
81
+ Math.abs(best.internalDate - snapshot.internalDate)
82
+ ? row
83
+ : best,
84
+ );
85
+ };
86
+
87
+ /**
88
+ * Match a fresh server snapshot against the mailbox's existing rows by
89
+ * Message-ID, falling back to an exact INTERNALDATE match for the (rare)
90
+ * messages with no usable Message-ID header on either side — mirrors the
91
+ * same headerless fallback `message-sync.ts` already uses for threading.
92
+ *
93
+ * Pure and side-effect free: the caller is responsible for acting on the
94
+ * three outcomes (rewrite / new-message sync / reconcile).
95
+ */
96
+ export const matchCursorRebuild = (
97
+ serverSnapshots: CursorRebuildSnapshot[],
98
+ existingRows: CursorRebuildRow[],
99
+ ): CursorRebuildMatchResult => {
100
+ const rowsByHeader = new Map<string, CursorRebuildRow[]>();
101
+ const rowsByInternalDateOnly = new Map<number, CursorRebuildRow[]>();
102
+
103
+ for (const row of existingRows) {
104
+ if (row.messageIdHeader) {
105
+ const list = rowsByHeader.get(row.messageIdHeader) ?? [];
106
+ list.push(row);
107
+ rowsByHeader.set(row.messageIdHeader, list);
108
+ } else {
109
+ const list = rowsByInternalDateOnly.get(row.internalDate) ?? [];
110
+ list.push(row);
111
+ rowsByInternalDateOnly.set(row.internalDate, list);
112
+ }
113
+ }
114
+
115
+ const consumed = new Set<string>();
116
+ const matched: CursorRebuildMatch[] = [];
117
+ const newUids: number[] = [];
118
+
119
+ for (const snapshot of serverSnapshots) {
120
+ const candidates = snapshot.messageId
121
+ ? (rowsByHeader.get(snapshot.messageId) ?? [])
122
+ : (rowsByInternalDateOnly.get(snapshot.internalDate) ?? []);
123
+ const row = pickClosestByInternalDate(snapshot, candidates, consumed);
124
+
125
+ if (!row) {
126
+ newUids.push(snapshot.uid);
127
+ continue;
128
+ }
129
+
130
+ consumed.add(row.messageId);
131
+ if (row.uid !== snapshot.uid) {
132
+ matched.push({
133
+ messageId: row.messageId,
134
+ oldUid: row.uid,
135
+ newUid: snapshot.uid,
136
+ ...(row.threadMessage ? { threadMessage: row.threadMessage } : {}),
137
+ });
138
+ }
139
+ }
140
+
141
+ const staleMessageIds = existingRows
142
+ .filter((row) => !consumed.has(row.messageId))
143
+ .map((row) => row.messageId);
144
+
145
+ return { matched, newUids, staleMessageIds };
146
+ };