@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 +1 -1
- package/src/body-sync-category-override.test.ts +3 -2
- package/src/body-sync.ts +4 -90
- package/src/index.ts +6 -0
- package/src/message-category.ts +100 -0
package/package.json
CHANGED
|
@@ -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.
|
|
347
|
-
//
|
|
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
|
|
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
|
+
};
|