@remit/mailbox-service 0.0.93 → 0.0.94

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.93",
3
+ "version": "0.0.94",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -343,8 +343,9 @@ describe("Address.flags.category overrides classification at sync time (issue #2
343
343
  );
344
344
 
345
345
  // The harness's default `retrieve` throws, so a storage read would fail
346
- // this outright. Issue #415 is where applying the override to this message
347
- // deliberately belongs; a sync pass is not it.
346
+ // this outright. Reaching back is the back-apply job's work (#415,
347
+ // `backApplySenderCategory`), fired by the flag PATCH; a sync pass is
348
+ // still not it.
348
349
  assert.equal(result.skippedCount, 1);
349
350
  assert.deepEqual(result.failedMessageIds, []);
350
351
  assert.deepEqual(harness.messageUpdates, []);
package/src/body-sync.ts CHANGED
@@ -62,6 +62,7 @@ import {
62
62
  resolveBlockedVsTrust,
63
63
  } from "./heuristics/classifyPlacement.js";
64
64
  import { extractSenderMismatch } from "./heuristics/senderMismatch.js";
65
+ import { denormalizeMessageCategory } from "./message-category.js";
65
66
  import type { PlacementMoveService } from "./placement-move.js";
66
67
  import { type QuarantineService, shapeFromMessageData } from "./quarantine.js";
67
68
  import { extractSnippetFromEmail } from "./snippet.js";
@@ -165,23 +166,6 @@ const extractSnippet = (parsed: ParsedMail): string =>
165
166
  SNIPPET_LENGTH,
166
167
  );
167
168
 
168
- /**
169
- * A row is skipped only when every field the denormalization would write
170
- * already matches. `snippet` and `listId` are absent from the update when the
171
- * message has neither, and an absent field is not a mismatch.
172
- */
173
- const alreadyDenormalized = (
174
- row: ThreadMessageItem,
175
- update: {
176
- category: ThreadMessageCategory;
177
- snippet?: string;
178
- listId?: string;
179
- },
180
- ): boolean =>
181
- row.category === update.category &&
182
- (update.snippet === undefined || row.snippet === update.snippet) &&
183
- (update.listId === undefined || row.listId === update.listId);
184
-
185
169
  /**
186
170
  * RFC 034 Decision 3.1: `Message.category` is written once and never mutated
187
171
  * after — RFC 030's message-list GSI sort key depends on it never churning.
@@ -928,12 +912,11 @@ export class BodySyncService {
928
912
 
929
913
  // Thread-list denormalization, using the same write-once category as the
930
914
  // Message update below — the two rows must never disagree on category.
931
- await this.denormalizeCategory(
915
+ await denormalizeMessageCategory(
916
+ { threadMessageService: this.threadMessageService },
932
917
  accountConfigId,
933
918
  messageId,
934
- finalCategory,
935
- snippet,
936
- listId,
919
+ { category: finalCategory, snippet, listId },
937
920
  );
938
921
 
939
922
  // `classificationState` records that the classifier ran, in the same
@@ -1891,73 +1874,4 @@ export class BodySyncService {
1891
1874
  throw err;
1892
1875
  }
1893
1876
  }
1894
-
1895
- /**
1896
- * Write the denormalized `category` (and optionally the snippet and the
1897
- * normalized `List-Id`) onto EVERY ThreadMessage row the message has — the
1898
- * copy the list/search read path serves without a per-row Message fetch.
1899
- *
1900
- * More than one row per messageId is schema-legal but not normally produced,
1901
- * and this iterates for the same reason `message-move.ts` does (see the model
1902
- * stated at its `deleteThreadMessagesForMessage`): the key permits it and
1903
- * nothing enforces otherwise. It is NOT the second mailbox a message appears
1904
- * in — `deriveMessageId` and `deriveThreadMessageId` are both
1905
- * mailbox-independent, so INBOX and Archive resolve to one row, and a copy
1906
- * gets its own messageId. The reachable case is thread-root drift: the same
1907
- * message re-saved under different `References`, which mints a second
1908
- * threadId and so a second row. Iterating is therefore hardening against a
1909
- * legal state, not a repair for one the sync path manufactures, which is why
1910
- * the tree's other single-row `messageId` lookups are correct as they stand.
1911
- * `flag-queue.ts` iterates the same list.
1912
- *
1913
- * Rows are looked up by messageId, so this does not depend on the RFC822
1914
- * Message-ID header — a headerless message still gets denormalized, matching
1915
- * the unconditional Message.category write. The composite set is built per
1916
- * row, never reused: `mailboxId` and `isRead` can differ between two rows for
1917
- * one message, and it is passed at all so that a future key-attribute
1918
- * addition touching the lsi3/lsi4/lsi5/gsi2 sort keys keeps the index rows
1919
- * consistent.
1920
- */
1921
- private async denormalizeCategory(
1922
- accountConfigId: string,
1923
- messageId: string,
1924
- category: ThreadMessageCategory,
1925
- snippet?: string,
1926
- listId?: string,
1927
- ): Promise<void> {
1928
- const rows = await this.threadMessageService.findAllByMessageId(
1929
- accountConfigId,
1930
- messageId,
1931
- );
1932
- if (rows.length === 0) {
1933
- throw new NotFoundError(
1934
- `ThreadMessage not found for message ${messageId}`,
1935
- );
1936
- }
1937
-
1938
- const update = {
1939
- category,
1940
- ...(snippet ? { snippet } : {}),
1941
- ...(listId ? { listId } : {}),
1942
- };
1943
-
1944
- for (const row of rows) {
1945
- if (alreadyDenormalized(row, update)) continue;
1946
- await this.threadMessageService.update(
1947
- accountConfigId,
1948
- row.threadMessageId,
1949
- update,
1950
- {
1951
- composites: {
1952
- sentDate: row.sentDate,
1953
- mailboxId: row.mailboxId,
1954
- isRead: row.isRead,
1955
- isDeleted: row.isDeleted,
1956
- hasStars: row.hasStars,
1957
- hasAttachment: row.hasAttachment,
1958
- },
1959
- },
1960
- );
1961
- }
1962
- }
1963
1877
  }
package/src/index.ts CHANGED
@@ -193,6 +193,12 @@ export {
193
193
  MailboxSyncService,
194
194
  type SyncAccountInfo,
195
195
  } from "./mailbox-sync.js";
196
+ export {
197
+ denormalizeMessageCategory,
198
+ type MessageCategoryDenormalizeDeps,
199
+ type MessageCategoryDenormalizeInput,
200
+ type MessageCategoryValue,
201
+ } from "./message-category.js";
196
202
  export {
197
203
  type DeleteMessagesOutcome,
198
204
  type DeleteOptions,
@@ -0,0 +1,100 @@
1
+ import type {
2
+ IThreadMessageRepository,
3
+ ThreadMessageItem,
4
+ } from "@remit/data-ports";
5
+ import { NotFoundError } from "@remit/data-ports/errors";
6
+
7
+ export type MessageCategoryValue = ThreadMessageItem["category"];
8
+
9
+ export interface MessageCategoryDenormalizeDeps {
10
+ threadMessageService: Pick<
11
+ IThreadMessageRepository,
12
+ "findAllByMessageId" | "update"
13
+ >;
14
+ }
15
+
16
+ export interface MessageCategoryDenormalizeInput {
17
+ category: MessageCategoryValue;
18
+ snippet?: string;
19
+ listId?: string;
20
+ }
21
+
22
+ /**
23
+ * A row is skipped only when every field the denormalization would write
24
+ * already matches. `snippet` and `listId` are absent from the update when the
25
+ * message has neither, and an absent field is not a mismatch.
26
+ */
27
+ const alreadyDenormalized = (
28
+ row: ThreadMessageItem,
29
+ update: MessageCategoryDenormalizeInput,
30
+ ): boolean =>
31
+ row.category === update.category &&
32
+ (update.snippet === undefined || row.snippet === update.snippet) &&
33
+ (update.listId === undefined || row.listId === update.listId);
34
+
35
+ /**
36
+ * Write a message's category (and, when the caller has them, its snippet and
37
+ * `List-Id`) onto every ThreadMessage row that indexes it. The thread row is
38
+ * what the message list renders and what the category filter matches, so it
39
+ * must never disagree with the Message row it mirrors.
40
+ *
41
+ * More than one row per messageId is schema-legal but not normally produced,
42
+ * and this iterates for the same reason `message-move.ts` does (see the model
43
+ * stated at its `deleteThreadMessagesForMessage`): the key permits it and
44
+ * nothing enforces otherwise. It is NOT the second mailbox a message appears
45
+ * in — `deriveMessageId` and `deriveThreadMessageId` are both
46
+ * mailbox-independent, so INBOX and Archive resolve to one row, and a copy
47
+ * gets its own messageId. The reachable case is thread-root drift: the same
48
+ * message re-saved under different `References`, which mints a second
49
+ * threadId and so a second row. Iterating is therefore hardening against a
50
+ * legal state, not a repair for one the sync path manufactures, which is why
51
+ * the tree's other single-row `messageId` lookups are correct as they stand.
52
+ * `flag-queue.ts` iterates the same list.
53
+ *
54
+ * Rows are looked up by messageId, so this does not depend on the RFC822
55
+ * Message-ID header — a headerless message still gets denormalized, matching
56
+ * the unconditional Message.category write. The composite set is built per
57
+ * row, never reused: `mailboxId` and `isRead` can differ between two rows for
58
+ * one message, and it is passed at all so that a future key-attribute
59
+ * addition touching the lsi3/lsi4/lsi5/gsi2 sort keys keeps the index rows
60
+ * consistent.
61
+ */
62
+ export const denormalizeMessageCategory = async (
63
+ deps: MessageCategoryDenormalizeDeps,
64
+ accountConfigId: string,
65
+ messageId: string,
66
+ update: MessageCategoryDenormalizeInput,
67
+ ): Promise<void> => {
68
+ const rows = await deps.threadMessageService.findAllByMessageId(
69
+ accountConfigId,
70
+ messageId,
71
+ );
72
+ if (rows.length === 0) {
73
+ throw new NotFoundError(`ThreadMessage not found for message ${messageId}`);
74
+ }
75
+
76
+ const written = {
77
+ category: update.category,
78
+ ...(update.snippet ? { snippet: update.snippet } : {}),
79
+ ...(update.listId ? { listId: update.listId } : {}),
80
+ };
81
+
82
+ for (const row of rows) {
83
+ if (alreadyDenormalized(row, written)) continue;
84
+ await deps.threadMessageService.update(
85
+ accountConfigId,
86
+ row.threadMessageId,
87
+ written,
88
+ {
89
+ composites: {
90
+ sentDate: row.sentDate,
91
+ mailboxId: row.mailboxId,
92
+ isRead: row.isRead,
93
+ isDeleted: row.isDeleted,
94
+ hasStars: row.hasStars,
95
+ hasAttachment: row.hasAttachment,
96
+ },
97
+ },
98
+ );
99
+ }
100
+ };