@remit/mailbox-service 0.0.25 → 0.0.27
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-backfill.test.ts +11 -9
- package/src/body-sync-category-denormalize.test.ts +341 -0
- package/src/body-sync-filter.test.ts +11 -9
- package/src/body-sync-quarantine.test.ts +11 -9
- package/src/body-sync.ts +99 -35
- package/src/mailbox-sync.test.ts +130 -1
- package/src/mailbox-sync.ts +16 -7
- package/src/message-sync-category.test.ts +132 -0
- package/src/message-sync-flags.test.ts +3 -1
- package/src/message-sync.ts +15 -0
package/package.json
CHANGED
|
@@ -72,15 +72,17 @@ const buildHarness = (
|
|
|
72
72
|
} as unknown as IMessageRepository;
|
|
73
73
|
|
|
74
74
|
const threadMessageService = {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
75
|
+
findAllByMessageId: async () => [
|
|
76
|
+
{
|
|
77
|
+
threadMessageId: "tm-1",
|
|
78
|
+
sentDate: 1,
|
|
79
|
+
mailboxId: "mb-1",
|
|
80
|
+
isRead: false,
|
|
81
|
+
isDeleted: false,
|
|
82
|
+
hasStars: false,
|
|
83
|
+
hasAttachment: false,
|
|
84
|
+
},
|
|
85
|
+
],
|
|
84
86
|
update: async (
|
|
85
87
|
_accountConfigId: string,
|
|
86
88
|
_threadMessageId: string,
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `thread_message.category` is the copy the list read path is about to filter
|
|
3
|
+
* on, and its write-path defects only appear under conditions a happy-path test
|
|
4
|
+
* never creates (issue #320).
|
|
5
|
+
*
|
|
6
|
+
* 1. The retro classification path wrote the Message before the ThreadMessage
|
|
7
|
+
* while its skip guard keyed off `message.category`. A failure between the
|
|
8
|
+
* two left the guard satisfied and the denormalized row stranded at
|
|
9
|
+
* `uncategorized` forever, because the requeued retry returned early.
|
|
10
|
+
* 2. `denormalizeCategory` resolved one arbitrary row per message through an
|
|
11
|
+
* unordered `.limit(1)`. More than one row per messageId is schema-legal —
|
|
12
|
+
* reachable through thread-root drift, not through a second mailbox, whose ids
|
|
13
|
+
* collapse to one row — and every row a message has must end up correct.
|
|
14
|
+
* 3. It also wrote only `category` on the retro path, so a copied message, which
|
|
15
|
+
* reaches no other denormalizing path, never got a `listId` at all.
|
|
16
|
+
*
|
|
17
|
+
* None of these show up on a single row in a single successful pass.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { describe, it } from "node:test";
|
|
22
|
+
import type {
|
|
23
|
+
IAddressRepository,
|
|
24
|
+
IEnvelopeRepository,
|
|
25
|
+
IMessageRepository,
|
|
26
|
+
IThreadMessageRepository,
|
|
27
|
+
MessageItem,
|
|
28
|
+
ThreadMessageItem,
|
|
29
|
+
UpdateMessageInput,
|
|
30
|
+
UpdateThreadMessageInput,
|
|
31
|
+
} from "@remit/data-ports";
|
|
32
|
+
import { MessageCategory } from "@remit/domain-enums";
|
|
33
|
+
import type { StorageService } from "@remit/storage-service";
|
|
34
|
+
import { BodySyncService } from "./body-sync.js";
|
|
35
|
+
import type { IImapConnection } from "./types.js";
|
|
36
|
+
|
|
37
|
+
const LINKEDIN_EML = Buffer.from(
|
|
38
|
+
[
|
|
39
|
+
"From: LinkedIn <messages-noreply@linkedin.com>",
|
|
40
|
+
"To: me@example.com",
|
|
41
|
+
"Subject: You have a new invitation",
|
|
42
|
+
"List-Id: LinkedIn Invitations <invitations.linkedin.com>",
|
|
43
|
+
"List-Unsubscribe: <https://www.linkedin.com/e/unsub>",
|
|
44
|
+
"Content-Type: text/plain",
|
|
45
|
+
"",
|
|
46
|
+
"invitation body text",
|
|
47
|
+
].join("\r\n"),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
type Row = Pick<
|
|
51
|
+
ThreadMessageItem,
|
|
52
|
+
| "threadMessageId"
|
|
53
|
+
| "messageId"
|
|
54
|
+
| "accountConfigId"
|
|
55
|
+
| "mailboxId"
|
|
56
|
+
| "sentDate"
|
|
57
|
+
| "isRead"
|
|
58
|
+
| "isDeleted"
|
|
59
|
+
| "hasStars"
|
|
60
|
+
| "hasAttachment"
|
|
61
|
+
| "category"
|
|
62
|
+
> &
|
|
63
|
+
Partial<Pick<ThreadMessageItem, "snippet" | "listId">>;
|
|
64
|
+
|
|
65
|
+
interface ThreadUpdate {
|
|
66
|
+
threadMessageId: string;
|
|
67
|
+
input: UpdateThreadMessageInput;
|
|
68
|
+
composites: Record<string, unknown> | undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface Harness {
|
|
72
|
+
service: BodySyncService;
|
|
73
|
+
message: MessageItem;
|
|
74
|
+
rows: Row[];
|
|
75
|
+
messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
|
|
76
|
+
threadUpdates: ThreadUpdate[];
|
|
77
|
+
writeOrder: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const buildRow = (
|
|
81
|
+
overrides: Partial<Row> & Pick<Row, "threadMessageId">,
|
|
82
|
+
): Row =>
|
|
83
|
+
({
|
|
84
|
+
messageId: "m-1",
|
|
85
|
+
accountConfigId: "cfg-1",
|
|
86
|
+
mailboxId: "mb-inbox",
|
|
87
|
+
sentDate: 1,
|
|
88
|
+
isRead: false,
|
|
89
|
+
isDeleted: false,
|
|
90
|
+
hasStars: false,
|
|
91
|
+
hasAttachment: false,
|
|
92
|
+
category: MessageCategory.uncategorized,
|
|
93
|
+
...overrides,
|
|
94
|
+
}) as Row;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The Message and ThreadMessage fixtures are mutated by their writes, so the
|
|
98
|
+
* skip guard sees the state a requeued delivery would. A harness that only
|
|
99
|
+
* records the write cannot show a stranded row: the strand is the guard reading
|
|
100
|
+
* a value that outlived a failed pass.
|
|
101
|
+
*/
|
|
102
|
+
const buildHarness = (
|
|
103
|
+
rows: Row[],
|
|
104
|
+
options: {
|
|
105
|
+
failThreadUpdates?: number;
|
|
106
|
+
bodyStored?: boolean;
|
|
107
|
+
} = {},
|
|
108
|
+
): Harness => {
|
|
109
|
+
const messageUpdates: Array<{
|
|
110
|
+
messageId: string;
|
|
111
|
+
input: UpdateMessageInput;
|
|
112
|
+
}> = [];
|
|
113
|
+
const threadUpdates: ThreadUpdate[] = [];
|
|
114
|
+
const writeOrder: string[] = [];
|
|
115
|
+
let threadUpdateAttempts = 0;
|
|
116
|
+
|
|
117
|
+
const message = {
|
|
118
|
+
messageId: "m-1",
|
|
119
|
+
mailboxId: "mb-inbox",
|
|
120
|
+
uid: 1,
|
|
121
|
+
category: MessageCategory.uncategorized,
|
|
122
|
+
...(options.bodyStored === false
|
|
123
|
+
? {}
|
|
124
|
+
: { bodyStorageKey: "s3://bodies/m-1" }),
|
|
125
|
+
} as unknown as MessageItem;
|
|
126
|
+
|
|
127
|
+
const messageService = {
|
|
128
|
+
get: async () => message,
|
|
129
|
+
update: async (messageId: string, input: UpdateMessageInput) => {
|
|
130
|
+
writeOrder.push("message");
|
|
131
|
+
messageUpdates.push({ messageId, input });
|
|
132
|
+
Object.assign(message, input);
|
|
133
|
+
},
|
|
134
|
+
} as unknown as IMessageRepository;
|
|
135
|
+
|
|
136
|
+
const threadMessageService = {
|
|
137
|
+
findAllByMessageId: async () => rows,
|
|
138
|
+
// Load-bearing, not a leftover: the current code never calls it, but
|
|
139
|
+
// reverting body-sync.ts to reproduce these failures does. Deleting it
|
|
140
|
+
// makes the fail-before run throw instead of demonstrating the defect.
|
|
141
|
+
getByMessageId: async () => rows[0],
|
|
142
|
+
update: async (
|
|
143
|
+
_accountConfigId: string,
|
|
144
|
+
threadMessageId: string,
|
|
145
|
+
input: UpdateThreadMessageInput,
|
|
146
|
+
updateOptions?: { composites?: Record<string, unknown> },
|
|
147
|
+
) => {
|
|
148
|
+
threadUpdateAttempts++;
|
|
149
|
+
if (threadUpdateAttempts <= (options.failThreadUpdates ?? 0)) {
|
|
150
|
+
throw new Error("thread_message write failed");
|
|
151
|
+
}
|
|
152
|
+
writeOrder.push("thread");
|
|
153
|
+
threadUpdates.push({
|
|
154
|
+
threadMessageId,
|
|
155
|
+
input,
|
|
156
|
+
composites: updateOptions?.composites,
|
|
157
|
+
});
|
|
158
|
+
const row = rows.find((r) => r.threadMessageId === threadMessageId);
|
|
159
|
+
if (row) Object.assign(row, input);
|
|
160
|
+
},
|
|
161
|
+
} as unknown as IThreadMessageRepository;
|
|
162
|
+
|
|
163
|
+
const storageService = {
|
|
164
|
+
retrieve: async () => LINKEDIN_EML,
|
|
165
|
+
storeMessageBody: async () => ({ uri: "s3://bodies/m-1" }),
|
|
166
|
+
storeParsedBody: async () => {},
|
|
167
|
+
listBodyParts: async () => [],
|
|
168
|
+
} as unknown as StorageService;
|
|
169
|
+
|
|
170
|
+
const service = new BodySyncService(
|
|
171
|
+
messageService,
|
|
172
|
+
storageService,
|
|
173
|
+
threadMessageService,
|
|
174
|
+
{ incrementInboundCount: async () => {} } as unknown as IAddressRepository,
|
|
175
|
+
{ listBodyParts: async () => [] } as unknown as IEnvelopeRepository,
|
|
176
|
+
{ info: () => {}, error: () => {} },
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
service,
|
|
181
|
+
message,
|
|
182
|
+
rows,
|
|
183
|
+
messageUpdates,
|
|
184
|
+
threadUpdates,
|
|
185
|
+
writeOrder,
|
|
186
|
+
};
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const backfill = (harness: Harness) =>
|
|
190
|
+
harness.service.syncBodies(["m-1"], "acc-1", "cfg-1", "INBOX", async () => {
|
|
191
|
+
throw new Error("the backfill must not open IMAP");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const syncFromImap = (harness: Harness) =>
|
|
195
|
+
harness.service.fetchAndGetBody(
|
|
196
|
+
"m-1",
|
|
197
|
+
"acc-1",
|
|
198
|
+
"cfg-1",
|
|
199
|
+
"INBOX",
|
|
200
|
+
async () =>
|
|
201
|
+
({
|
|
202
|
+
openBox: async () => {},
|
|
203
|
+
fetchMessageBody: async () => LINKEDIN_EML,
|
|
204
|
+
}) as unknown as IImapConnection,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
describe("the classification backfill survives an interrupted write", () => {
|
|
208
|
+
it("leaves the Message unclassified when the ThreadMessage write fails, so the retry runs again", async () => {
|
|
209
|
+
const harness = buildHarness([buildRow({ threadMessageId: "tm-1" })], {
|
|
210
|
+
failThreadUpdates: 1,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const failed = await backfill(harness);
|
|
214
|
+
|
|
215
|
+
assert.deepEqual(failed.failedMessageIds, ["m-1"]);
|
|
216
|
+
assert.equal(harness.message.category, MessageCategory.uncategorized);
|
|
217
|
+
assert.deepEqual(harness.messageUpdates, []);
|
|
218
|
+
|
|
219
|
+
const retried = await backfill(harness);
|
|
220
|
+
|
|
221
|
+
assert.deepEqual(retried.failedMessageIds, []);
|
|
222
|
+
assert.equal(harness.message.category, MessageCategory.social);
|
|
223
|
+
assert.equal(harness.rows[0].category, MessageCategory.social);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("writes the Message only after the denormalized row is durable", async () => {
|
|
227
|
+
const harness = buildHarness([buildRow({ threadMessageId: "tm-1" })]);
|
|
228
|
+
|
|
229
|
+
await backfill(harness);
|
|
230
|
+
|
|
231
|
+
assert.deepEqual(harness.writeOrder, ["thread", "message"]);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("denormalization reaches every row a message has", () => {
|
|
236
|
+
it("carries the backfilled category onto all of a message's rows", async () => {
|
|
237
|
+
const harness = buildHarness([
|
|
238
|
+
buildRow({ threadMessageId: "tm-inbox", mailboxId: "mb-inbox" }),
|
|
239
|
+
buildRow({
|
|
240
|
+
threadMessageId: "tm-archive",
|
|
241
|
+
mailboxId: "mb-archive",
|
|
242
|
+
isRead: true,
|
|
243
|
+
}),
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
await backfill(harness);
|
|
247
|
+
|
|
248
|
+
assert.deepEqual(
|
|
249
|
+
harness.rows.map((r) => r.category),
|
|
250
|
+
[MessageCategory.social, MessageCategory.social],
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("builds the composite block from each row rather than reusing the first", async () => {
|
|
255
|
+
const harness = buildHarness([
|
|
256
|
+
buildRow({ threadMessageId: "tm-inbox", mailboxId: "mb-inbox" }),
|
|
257
|
+
buildRow({
|
|
258
|
+
threadMessageId: "tm-archive",
|
|
259
|
+
mailboxId: "mb-archive",
|
|
260
|
+
isRead: true,
|
|
261
|
+
}),
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
await backfill(harness);
|
|
265
|
+
|
|
266
|
+
assert.deepEqual(
|
|
267
|
+
harness.threadUpdates.map((u) => ({
|
|
268
|
+
threadMessageId: u.threadMessageId,
|
|
269
|
+
mailboxId: u.composites?.mailboxId,
|
|
270
|
+
isRead: u.composites?.isRead,
|
|
271
|
+
})),
|
|
272
|
+
[
|
|
273
|
+
{ threadMessageId: "tm-inbox", mailboxId: "mb-inbox", isRead: false },
|
|
274
|
+
{
|
|
275
|
+
threadMessageId: "tm-archive",
|
|
276
|
+
mailboxId: "mb-archive",
|
|
277
|
+
isRead: true,
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("fans the snippet and the list id out to every row on the sync path", async () => {
|
|
284
|
+
// A snippet and a List-Id belong to the message, not to the mailbox a row
|
|
285
|
+
// happens to name, so every row carries them.
|
|
286
|
+
const harness = buildHarness(
|
|
287
|
+
[
|
|
288
|
+
buildRow({ threadMessageId: "tm-inbox", mailboxId: "mb-inbox" }),
|
|
289
|
+
buildRow({ threadMessageId: "tm-archive", mailboxId: "mb-archive" }),
|
|
290
|
+
],
|
|
291
|
+
{ bodyStored: false },
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
await syncFromImap(harness);
|
|
295
|
+
|
|
296
|
+
for (const row of harness.rows) {
|
|
297
|
+
assert.equal(row.category, MessageCategory.social);
|
|
298
|
+
assert.equal(row.listId, "invitations.linkedin.com");
|
|
299
|
+
assert.equal(row.snippet, "invitation body text");
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("writes nothing for a row that already carries every value", async () => {
|
|
304
|
+
const harness = buildHarness([
|
|
305
|
+
buildRow({
|
|
306
|
+
threadMessageId: "tm-inbox",
|
|
307
|
+
mailboxId: "mb-inbox",
|
|
308
|
+
category: MessageCategory.social,
|
|
309
|
+
snippet: "invitation body text",
|
|
310
|
+
listId: "invitations.linkedin.com",
|
|
311
|
+
}),
|
|
312
|
+
buildRow({ threadMessageId: "tm-archive", mailboxId: "mb-archive" }),
|
|
313
|
+
]);
|
|
314
|
+
|
|
315
|
+
await backfill(harness);
|
|
316
|
+
|
|
317
|
+
assert.deepEqual(
|
|
318
|
+
harness.threadUpdates.map((u) => u.threadMessageId),
|
|
319
|
+
["tm-archive"],
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe("the classification backfill writes the whole denormalized set", () => {
|
|
325
|
+
// A copied message inherits its source's stored body AND its decided
|
|
326
|
+
// category, so the full body-store path skips it and this path's guard
|
|
327
|
+
// returns early — and nothing but these two paths ever writes `listId`. The
|
|
328
|
+
// backfill passing only `category` left every copy's `list_id` NULL for good.
|
|
329
|
+
it("carries the list id and the snippet, not just the category", async () => {
|
|
330
|
+
const harness = buildHarness([buildRow({ threadMessageId: "tm-1" })]);
|
|
331
|
+
|
|
332
|
+
await backfill(harness);
|
|
333
|
+
|
|
334
|
+
assert.deepEqual(harness.threadUpdates.length, 1);
|
|
335
|
+
assert.deepEqual(harness.threadUpdates[0].input, {
|
|
336
|
+
category: MessageCategory.social,
|
|
337
|
+
snippet: "invitation body text",
|
|
338
|
+
listId: "invitations.linkedin.com",
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
});
|
|
@@ -102,15 +102,17 @@ const buildHarness = (
|
|
|
102
102
|
} as unknown as IMessageRepository;
|
|
103
103
|
|
|
104
104
|
const threadMessageService = {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
105
|
+
findAllByMessageId: async () => [
|
|
106
|
+
{
|
|
107
|
+
threadMessageId: "tm-1",
|
|
108
|
+
sentDate: 1,
|
|
109
|
+
mailboxId: message.mailboxId,
|
|
110
|
+
isRead: false,
|
|
111
|
+
isDeleted: false,
|
|
112
|
+
hasStars: false,
|
|
113
|
+
hasAttachment: false,
|
|
114
|
+
},
|
|
115
|
+
],
|
|
114
116
|
update: async () => {},
|
|
115
117
|
} as unknown as IThreadMessageRepository;
|
|
116
118
|
|
|
@@ -78,15 +78,17 @@ const buildHarness = (options: {
|
|
|
78
78
|
messageService,
|
|
79
79
|
storageService,
|
|
80
80
|
{
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
81
|
+
findAllByMessageId: async () => [
|
|
82
|
+
{
|
|
83
|
+
threadMessageId: "tm-1",
|
|
84
|
+
sentDate: 1,
|
|
85
|
+
mailboxId: "mbx-1",
|
|
86
|
+
isRead: false,
|
|
87
|
+
isDeleted: false,
|
|
88
|
+
hasStars: false,
|
|
89
|
+
hasAttachment: false,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
90
92
|
update: async () => {},
|
|
91
93
|
} as unknown as IThreadMessageRepository,
|
|
92
94
|
{} as unknown as IAddressRepository,
|
package/src/body-sync.ts
CHANGED
|
@@ -138,6 +138,37 @@ const toFilterMessage = (parsed: ParsedMail): FilterMessage => ({
|
|
|
138
138
|
listId: extractListId(parsed),
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
const SNIPPET_LENGTH = 256;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The snippet the list row shows, from whichever body part carries text. Shared
|
|
145
|
+
* by both paths that denormalize onto the ThreadMessage so they cannot derive it
|
|
146
|
+
* differently.
|
|
147
|
+
*/
|
|
148
|
+
const extractSnippet = (parsed: ParsedMail): string =>
|
|
149
|
+
extractSnippetFromEmail(
|
|
150
|
+
parsed.text,
|
|
151
|
+
typeof parsed.html === "string" ? parsed.html : undefined,
|
|
152
|
+
SNIPPET_LENGTH,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A row is skipped only when every field the denormalization would write
|
|
157
|
+
* already matches. `snippet` and `listId` are absent from the update when the
|
|
158
|
+
* message has neither, and an absent field is not a mismatch.
|
|
159
|
+
*/
|
|
160
|
+
const alreadyDenormalized = (
|
|
161
|
+
row: ThreadMessageItem,
|
|
162
|
+
update: {
|
|
163
|
+
category: ThreadMessageCategory;
|
|
164
|
+
snippet?: string;
|
|
165
|
+
listId?: string;
|
|
166
|
+
},
|
|
167
|
+
): boolean =>
|
|
168
|
+
row.category === update.category &&
|
|
169
|
+
(update.snippet === undefined || row.snippet === update.snippet) &&
|
|
170
|
+
(update.listId === undefined || row.listId === update.listId);
|
|
171
|
+
|
|
141
172
|
export const toParsedBody = (parsed: ParsedMail): ParsedBody => ({
|
|
142
173
|
text: parsed.text ?? null,
|
|
143
174
|
html: typeof parsed.html === "string" ? parsed.html : null,
|
|
@@ -942,12 +973,26 @@ export class BodySyncService {
|
|
|
942
973
|
const parsed = await parseMessageBody(body);
|
|
943
974
|
const classification = this.classifyMessage(parsed);
|
|
944
975
|
|
|
945
|
-
|
|
976
|
+
// Same order as {@link applyPostStoreSteps}, for the same reason: the
|
|
977
|
+
// signal the skip guard reads is written last. `message.category` is that
|
|
978
|
+
// signal here, so a failure between the two writes leaves both undone and
|
|
979
|
+
// the requeued retry redoes both. Writing the Message first strands the
|
|
980
|
+
// denormalized row at `uncategorized` forever — the guard is satisfied and
|
|
981
|
+
// the retry returns early (issue #320).
|
|
982
|
+
// The same three denormalized fields `updateSnippets` writes on the
|
|
983
|
+
// full body-store path, not just the category. A copied message inherits
|
|
984
|
+
// `bodyStorageKey` and a decided category from its source, so it reaches
|
|
985
|
+
// neither that path nor this one's classification — but nothing else ever
|
|
986
|
+
// writes `listId`, so leaving it out here made a copy's `list_id`
|
|
987
|
+
// permanently NULL. Both are derived from the same bytes already in hand.
|
|
946
988
|
await this.denormalizeCategory(
|
|
947
989
|
accountConfigId,
|
|
948
990
|
message.messageId,
|
|
949
991
|
classification.category,
|
|
992
|
+
extractSnippet(parsed),
|
|
993
|
+
extractListId(parsed),
|
|
950
994
|
);
|
|
995
|
+
await this.messageService.update(message.messageId, classification);
|
|
951
996
|
|
|
952
997
|
this.log.info(
|
|
953
998
|
{ messageId: message.messageId, category: classification.category },
|
|
@@ -1445,13 +1490,7 @@ export class BodySyncService {
|
|
|
1445
1490
|
// two apart and quarantine only this one (issue #72).
|
|
1446
1491
|
const parsed = await parseMessageBody(body);
|
|
1447
1492
|
|
|
1448
|
-
|
|
1449
|
-
const snippet = extractSnippetFromEmail(
|
|
1450
|
-
parsed.text,
|
|
1451
|
-
typeof parsed.html === "string" ? parsed.html : undefined,
|
|
1452
|
-
256,
|
|
1453
|
-
);
|
|
1454
|
-
|
|
1493
|
+
const snippet = extractSnippet(parsed);
|
|
1455
1494
|
const category = classifyByHeaders(parsed);
|
|
1456
1495
|
const listId = extractListId(parsed);
|
|
1457
1496
|
|
|
@@ -1472,15 +1511,30 @@ export class BodySyncService {
|
|
|
1472
1511
|
}
|
|
1473
1512
|
|
|
1474
1513
|
/**
|
|
1475
|
-
* Write the denormalized `category` (and optionally the snippet
|
|
1476
|
-
*
|
|
1477
|
-
* without a per-row Message fetch.
|
|
1514
|
+
* Write the denormalized `category` (and optionally the snippet and the
|
|
1515
|
+
* normalized `List-Id`) onto EVERY ThreadMessage row the message has — the
|
|
1516
|
+
* copy the list/search read path serves without a per-row Message fetch.
|
|
1478
1517
|
*
|
|
1479
|
-
*
|
|
1480
|
-
*
|
|
1481
|
-
*
|
|
1482
|
-
*
|
|
1483
|
-
*
|
|
1518
|
+
* More than one row per messageId is schema-legal but not normally produced,
|
|
1519
|
+
* and this iterates for the same reason `message-move.ts` does (see the model
|
|
1520
|
+
* stated at its `deleteThreadMessagesForMessage`): the key permits it and
|
|
1521
|
+
* nothing enforces otherwise. It is NOT the second mailbox a message appears
|
|
1522
|
+
* in — `deriveMessageId` and `deriveThreadMessageId` are both
|
|
1523
|
+
* mailbox-independent, so INBOX and Archive resolve to one row, and a copy
|
|
1524
|
+
* gets its own messageId. The reachable case is thread-root drift: the same
|
|
1525
|
+
* message re-saved under different `References`, which mints a second
|
|
1526
|
+
* threadId and so a second row. Iterating is therefore hardening against a
|
|
1527
|
+
* legal state, not a repair for one the sync path manufactures, which is why
|
|
1528
|
+
* the tree's other single-row `messageId` lookups are correct as they stand.
|
|
1529
|
+
* `flag-queue.ts` iterates the same list.
|
|
1530
|
+
*
|
|
1531
|
+
* Rows are looked up by messageId, so this does not depend on the RFC822
|
|
1532
|
+
* Message-ID header — a headerless message still gets denormalized, matching
|
|
1533
|
+
* the unconditional Message.category write. The composite set is built per
|
|
1534
|
+
* row, never reused: `mailboxId` and `isRead` can differ between two rows for
|
|
1535
|
+
* one message, and it is passed at all so that a future key-attribute
|
|
1536
|
+
* addition touching the lsi3/lsi4/lsi5/gsi2 sort keys keeps the index rows
|
|
1537
|
+
* consistent.
|
|
1484
1538
|
*/
|
|
1485
1539
|
private async denormalizeCategory(
|
|
1486
1540
|
accountConfigId: string,
|
|
@@ -1489,29 +1543,39 @@ export class BodySyncService {
|
|
|
1489
1543
|
snippet?: string,
|
|
1490
1544
|
listId?: string,
|
|
1491
1545
|
): Promise<void> {
|
|
1492
|
-
const
|
|
1546
|
+
const rows = await this.threadMessageService.findAllByMessageId(
|
|
1493
1547
|
accountConfigId,
|
|
1494
1548
|
messageId,
|
|
1495
1549
|
);
|
|
1550
|
+
if (rows.length === 0) {
|
|
1551
|
+
throw new NotFoundError(
|
|
1552
|
+
`ThreadMessage not found for message ${messageId}`,
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1496
1555
|
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
{
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1556
|
+
const update = {
|
|
1557
|
+
category,
|
|
1558
|
+
...(snippet ? { snippet } : {}),
|
|
1559
|
+
...(listId ? { listId } : {}),
|
|
1560
|
+
};
|
|
1561
|
+
|
|
1562
|
+
for (const row of rows) {
|
|
1563
|
+
if (alreadyDenormalized(row, update)) continue;
|
|
1564
|
+
await this.threadMessageService.update(
|
|
1565
|
+
accountConfigId,
|
|
1566
|
+
row.threadMessageId,
|
|
1567
|
+
update,
|
|
1568
|
+
{
|
|
1569
|
+
composites: {
|
|
1570
|
+
sentDate: row.sentDate,
|
|
1571
|
+
mailboxId: row.mailboxId,
|
|
1572
|
+
isRead: row.isRead,
|
|
1573
|
+
isDeleted: row.isDeleted,
|
|
1574
|
+
hasStars: row.hasStars,
|
|
1575
|
+
hasAttachment: row.hasAttachment,
|
|
1576
|
+
},
|
|
1513
1577
|
},
|
|
1514
|
-
|
|
1515
|
-
|
|
1578
|
+
);
|
|
1579
|
+
}
|
|
1516
1580
|
}
|
|
1517
1581
|
}
|
package/src/mailbox-sync.test.ts
CHANGED
|
@@ -4,7 +4,11 @@ import type {
|
|
|
4
4
|
IMailboxRepository,
|
|
5
5
|
IMailboxSpecialUseRepository,
|
|
6
6
|
} from "@remit/data-ports";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
MailboxCursorState,
|
|
9
|
+
MailboxSpecialUse,
|
|
10
|
+
MailboxSyncStatus,
|
|
11
|
+
} from "@remit/domain-enums";
|
|
8
12
|
import { parseImapAttributes } from "./attribute-mapper.js";
|
|
9
13
|
import { MailboxSyncService } from "./mailbox-sync.js";
|
|
10
14
|
import type { IImapConnection, ImapNamespaces } from "./types.js";
|
|
@@ -180,3 +184,128 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
|
|
|
180
184
|
assert.equal("cursorState" in (uidValidityUpdate ?? {}), false);
|
|
181
185
|
});
|
|
182
186
|
});
|
|
187
|
+
|
|
188
|
+
describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending folders (#290)", () => {
|
|
189
|
+
const namespaces: ImapNamespaces = {
|
|
190
|
+
personal: [{ prefix: "", delimiter: "/" }],
|
|
191
|
+
other: [],
|
|
192
|
+
shared: [],
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// The server lists only INBOX: neither user folder is on it yet.
|
|
196
|
+
const serverConnection = (): IImapConnection =>
|
|
197
|
+
({
|
|
198
|
+
getNamespaces: async () => namespaces,
|
|
199
|
+
listMailboxes: async () => [
|
|
200
|
+
{
|
|
201
|
+
fullPath: "INBOX",
|
|
202
|
+
name: "INBOX",
|
|
203
|
+
delimiter: "/",
|
|
204
|
+
attributes: [],
|
|
205
|
+
parentPath: null,
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
getMailboxStatus: async () => ({
|
|
209
|
+
messages: 0,
|
|
210
|
+
recent: 0,
|
|
211
|
+
unseen: 0,
|
|
212
|
+
uidNext: 1,
|
|
213
|
+
uidValidity: 1,
|
|
214
|
+
highestModseq: "0",
|
|
215
|
+
deletedCount: 0,
|
|
216
|
+
}),
|
|
217
|
+
}) as unknown as IImapConnection;
|
|
218
|
+
|
|
219
|
+
const buildServices = (
|
|
220
|
+
existing: Array<{
|
|
221
|
+
mailboxId: string;
|
|
222
|
+
fullPath: string;
|
|
223
|
+
syncStatus?: string;
|
|
224
|
+
}>,
|
|
225
|
+
) => {
|
|
226
|
+
const deleted: string[] = [];
|
|
227
|
+
const mailboxService = {
|
|
228
|
+
listByAccount: async () => ({
|
|
229
|
+
items: existing.map((m) => ({
|
|
230
|
+
mailboxId: m.mailboxId,
|
|
231
|
+
fullPath: m.fullPath,
|
|
232
|
+
uidNext: 1,
|
|
233
|
+
uidValidity: 1,
|
|
234
|
+
messageCount: 0,
|
|
235
|
+
unseenCount: 0,
|
|
236
|
+
deletedCount: 0,
|
|
237
|
+
highestModseq: "0",
|
|
238
|
+
specialUse: undefined,
|
|
239
|
+
syncStatus: m.syncStatus,
|
|
240
|
+
})),
|
|
241
|
+
continuationToken: undefined,
|
|
242
|
+
}),
|
|
243
|
+
update: async () => ({}),
|
|
244
|
+
delete: async (_accountId: string, mailboxId: string) => {
|
|
245
|
+
deleted.push(mailboxId);
|
|
246
|
+
},
|
|
247
|
+
create: async () => ({}),
|
|
248
|
+
} as unknown as IMailboxRepository;
|
|
249
|
+
|
|
250
|
+
const specialUseService = {
|
|
251
|
+
listByMailboxId: async () => [],
|
|
252
|
+
deleteByMailboxId: async () => undefined,
|
|
253
|
+
createMany: async () => undefined,
|
|
254
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
255
|
+
|
|
256
|
+
return { mailboxService, specialUseService, deleted };
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
it("keeps a pending folder the server has not listed yet", async () => {
|
|
260
|
+
// The folder was just created locally; MAILBOX_CREATE has not reached the
|
|
261
|
+
// server, so the LIST omits it. Deleting the row here races the create and
|
|
262
|
+
// wedges the account's mailbox-sync FIFO group for a full visibility window.
|
|
263
|
+
const { mailboxService, specialUseService, deleted } = buildServices([
|
|
264
|
+
{
|
|
265
|
+
mailboxId: "inbox",
|
|
266
|
+
fullPath: "INBOX",
|
|
267
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
mailboxId: "pending-folder",
|
|
271
|
+
fullPath: "New Folder",
|
|
272
|
+
syncStatus: MailboxSyncStatus.pending,
|
|
273
|
+
},
|
|
274
|
+
]);
|
|
275
|
+
const service = new MailboxSyncService(mailboxService, specialUseService);
|
|
276
|
+
|
|
277
|
+
const result = await service.syncMailboxes(
|
|
278
|
+
{ accountId: "acc-1" },
|
|
279
|
+
serverConnection(),
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
assert.deepEqual(deleted, []);
|
|
283
|
+
assert.equal(result.deleted, 0);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("still deletes a synced folder that has left the server", async () => {
|
|
287
|
+
// A folder that was confirmed on the server and is now gone is a genuine
|
|
288
|
+
// server-side deletion; the reconcile must still reap it.
|
|
289
|
+
const { mailboxService, specialUseService, deleted } = buildServices([
|
|
290
|
+
{
|
|
291
|
+
mailboxId: "inbox",
|
|
292
|
+
fullPath: "INBOX",
|
|
293
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
mailboxId: "synced-gone",
|
|
297
|
+
fullPath: "Old Folder",
|
|
298
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
299
|
+
},
|
|
300
|
+
]);
|
|
301
|
+
const service = new MailboxSyncService(mailboxService, specialUseService);
|
|
302
|
+
|
|
303
|
+
const result = await service.syncMailboxes(
|
|
304
|
+
{ accountId: "acc-1" },
|
|
305
|
+
serverConnection(),
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
assert.deepEqual(deleted, ["synced-gone"]);
|
|
309
|
+
assert.equal(result.deleted, 1);
|
|
310
|
+
});
|
|
311
|
+
});
|
package/src/mailbox-sync.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
import {
|
|
14
14
|
MailboxCursorState,
|
|
15
15
|
MailboxSpecialUse,
|
|
16
|
+
MailboxSyncStatus,
|
|
16
17
|
NamespaceType,
|
|
17
18
|
} from "@remit/domain-enums";
|
|
18
19
|
import pMap from "p-map";
|
|
@@ -198,13 +199,21 @@ export class MailboxSyncService {
|
|
|
198
199
|
|
|
199
200
|
// Handle deleted mailboxes (exist in DB but not on server)
|
|
200
201
|
for (const existing of existingMailboxes) {
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
202
|
+
if (seenPaths.has(existing.fullPath)) continue;
|
|
203
|
+
// A `pending` row is a folder the user just created (or renamed) whose
|
|
204
|
+
// MAILBOX_CREATE/RENAME has not yet reached the server, so its absence
|
|
205
|
+
// from the LIST is expected, not a server-side deletion. Deleting it
|
|
206
|
+
// races the create: the row vanishes, then MAILBOX_CREATE fails with
|
|
207
|
+
// NotFoundError trying to mark it synced, and — sharing this account's
|
|
208
|
+
// mailboxes FIFO group — that un-acked failure stalls every later
|
|
209
|
+
// mailbox sync for the queue's whole visibility window (#290). Leave
|
|
210
|
+
// pending rows to the create/rename flow that owns them.
|
|
211
|
+
if (existing.syncStatus === MailboxSyncStatus.pending) continue;
|
|
212
|
+
await this.mailboxService.delete(account.accountId, existing.mailboxId);
|
|
213
|
+
console.info(
|
|
214
|
+
`Deleted mailbox: ${existing.mailboxId} (${existing.fullPath})`,
|
|
215
|
+
);
|
|
216
|
+
result.deleted++;
|
|
208
217
|
}
|
|
209
218
|
|
|
210
219
|
return result;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A ThreadMessage row created for a message that is already classified used to
|
|
3
|
+
* start at `uncategorized` and stay there: `denormalizeCategory` runs at
|
|
4
|
+
* body-sync, and body-sync skips a message whose body is already stored (issue
|
|
5
|
+
* #320). A second mailbox does not produce such a row — the ids are
|
|
6
|
+
* mailbox-independent — but thread-root drift does: the same message re-saved
|
|
7
|
+
* under different `References` mints a second threadId, so a new row for a
|
|
8
|
+
* Message whose category is already decided.
|
|
9
|
+
*
|
|
10
|
+
* The row now carries the category of the Message the same save just wrote.
|
|
11
|
+
* These pin the create input, which is where the threading can go wrong; the
|
|
12
|
+
* stored round-trip belongs to #322's conformance suite. The negative case is
|
|
13
|
+
* here because parameter threading is exactly the change that starts writing the
|
|
14
|
+
* wrong value when a caller's variable is undefined — and `uncategorized` is the
|
|
15
|
+
* declared pending state, never to be folded into `personal` (issue #45).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { describe, it } from "node:test";
|
|
20
|
+
import type {
|
|
21
|
+
CreateMessageInput,
|
|
22
|
+
CreateThreadMessageInput,
|
|
23
|
+
IAddressRepository,
|
|
24
|
+
IEnvelopeRepository,
|
|
25
|
+
IMailboxRepository,
|
|
26
|
+
IMessageRepository,
|
|
27
|
+
IThreadMessageRepository,
|
|
28
|
+
MessageItem,
|
|
29
|
+
ThreadMessageItem,
|
|
30
|
+
} from "@remit/data-ports";
|
|
31
|
+
import { MessageCategory } from "@remit/domain-enums";
|
|
32
|
+
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
33
|
+
import { MessageSyncService } from "./message-sync.js";
|
|
34
|
+
import type { ImapMessage } from "./types.js";
|
|
35
|
+
|
|
36
|
+
const envelope = {
|
|
37
|
+
date: new Date(0).toISOString(),
|
|
38
|
+
messageId: "<root@example.com>",
|
|
39
|
+
subject: "Subject",
|
|
40
|
+
from: [{ name: "Sender", mailbox: "sender", host: "example.com" }],
|
|
41
|
+
sender: [],
|
|
42
|
+
replyTo: [],
|
|
43
|
+
to: [],
|
|
44
|
+
cc: [],
|
|
45
|
+
bcc: [],
|
|
46
|
+
inReplyTo: "",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const imapMessage = {
|
|
50
|
+
uid: 42,
|
|
51
|
+
seq: 1,
|
|
52
|
+
size: 100,
|
|
53
|
+
internalDate: new Date(0),
|
|
54
|
+
flags: [],
|
|
55
|
+
envelope,
|
|
56
|
+
} as unknown as ImapMessage;
|
|
57
|
+
|
|
58
|
+
const stub = <T>(): T => ({}) as T;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Drive the real save path so the value the ThreadMessage gets is the one the
|
|
62
|
+
* Message write returned, not one a test handed to a private method.
|
|
63
|
+
*/
|
|
64
|
+
const saveIntoMailbox = async (
|
|
65
|
+
storedCategory: MessageItem["category"],
|
|
66
|
+
): Promise<CreateThreadMessageInput> => {
|
|
67
|
+
const inputs: CreateThreadMessageInput[] = [];
|
|
68
|
+
|
|
69
|
+
const messageService = {
|
|
70
|
+
upsertWithStatus: async (input: CreateMessageInput) => ({
|
|
71
|
+
item: {
|
|
72
|
+
...input,
|
|
73
|
+
category: storedCategory,
|
|
74
|
+
} as unknown as MessageItem,
|
|
75
|
+
created: false,
|
|
76
|
+
}),
|
|
77
|
+
} as unknown as IMessageRepository;
|
|
78
|
+
|
|
79
|
+
const threadMessageService = {
|
|
80
|
+
create: async (input: CreateThreadMessageInput) => {
|
|
81
|
+
inputs.push(input);
|
|
82
|
+
return input as unknown as ThreadMessageItem;
|
|
83
|
+
},
|
|
84
|
+
} as unknown as IThreadMessageRepository;
|
|
85
|
+
|
|
86
|
+
const envelopeService = {
|
|
87
|
+
upsertEnvelope: async () => {},
|
|
88
|
+
upsertBodyParts: async () => {},
|
|
89
|
+
} as unknown as IEnvelopeRepository;
|
|
90
|
+
|
|
91
|
+
const addressService = {
|
|
92
|
+
upsertAddress: async () => {},
|
|
93
|
+
upsertEnvelopeAddress: async () => {},
|
|
94
|
+
} as unknown as IAddressRepository;
|
|
95
|
+
|
|
96
|
+
const service = new MessageSyncService(
|
|
97
|
+
stub<ManagedConnectionFactory>(),
|
|
98
|
+
stub<IMailboxRepository>(),
|
|
99
|
+
messageService,
|
|
100
|
+
envelopeService,
|
|
101
|
+
addressService,
|
|
102
|
+
threadMessageService,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
await (
|
|
106
|
+
service as unknown as {
|
|
107
|
+
saveMessage: (
|
|
108
|
+
mailboxId: string,
|
|
109
|
+
accountId: string,
|
|
110
|
+
accountConfigId: string,
|
|
111
|
+
msg: ImapMessage,
|
|
112
|
+
) => Promise<unknown>;
|
|
113
|
+
}
|
|
114
|
+
).saveMessage("mbx-1", "acct-1", "cfg-1", imapMessage);
|
|
115
|
+
|
|
116
|
+
assert.equal(inputs.length, 1);
|
|
117
|
+
const [input] = inputs;
|
|
118
|
+
assert.ok(input);
|
|
119
|
+
return input;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
describe("a created ThreadMessage carries the Message's category", () => {
|
|
123
|
+
it("gives a row for an already-classified message that message's category", async () => {
|
|
124
|
+
const input = await saveIntoMailbox(MessageCategory.newsletter);
|
|
125
|
+
assert.equal(input.category, MessageCategory.newsletter);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("leaves a row for a new, unclassified message at uncategorized", async () => {
|
|
129
|
+
const input = await saveIntoMailbox(MessageCategory.uncategorized);
|
|
130
|
+
assert.equal(input.category, MessageCategory.uncategorized);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
IThreadMessageRepository,
|
|
10
10
|
ThreadMessageItem,
|
|
11
11
|
} from "@remit/data-ports";
|
|
12
|
-
import { MessageSystemFlag } from "@remit/domain-enums";
|
|
12
|
+
import { MessageCategory, MessageSystemFlag } from "@remit/domain-enums";
|
|
13
13
|
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
14
14
|
import { MessageSyncService } from "./message-sync.js";
|
|
15
15
|
import type { ImapEnvelope } from "./types.js";
|
|
@@ -72,6 +72,7 @@ type CreateThreadForMessage = (
|
|
|
72
72
|
sentDate: number,
|
|
73
73
|
envelope: ImapEnvelope,
|
|
74
74
|
flags: string[],
|
|
75
|
+
category: ThreadMessageItem["category"],
|
|
75
76
|
references?: string[],
|
|
76
77
|
hasAttachment?: boolean,
|
|
77
78
|
) => Promise<void>;
|
|
@@ -103,6 +104,7 @@ const createThreadWithFlags = async (
|
|
|
103
104
|
now,
|
|
104
105
|
envelope,
|
|
105
106
|
flags,
|
|
107
|
+
MessageCategory.uncategorized,
|
|
106
108
|
);
|
|
107
109
|
|
|
108
110
|
assert.equal(inputs.length, 1);
|
package/src/message-sync.ts
CHANGED
|
@@ -1317,6 +1317,15 @@ export class MessageSyncService {
|
|
|
1317
1317
|
sentDate,
|
|
1318
1318
|
envelope,
|
|
1319
1319
|
msg.flags,
|
|
1320
|
+
// The category of the Message this same save just wrote. A new
|
|
1321
|
+
// message is `uncategorized` until body-sync classifies it; a row
|
|
1322
|
+
// created for a Message that is ALREADY classified takes the decided
|
|
1323
|
+
// value rather than sitting stale forever behind a body-sync pass that
|
|
1324
|
+
// will skip it. That happens on thread-root drift — the same message
|
|
1325
|
+
// re-saved under different `References` mints a second threadId and so
|
|
1326
|
+
// a second row — not on a second mailbox, which resolves to the row
|
|
1327
|
+
// that already exists (issue #320).
|
|
1328
|
+
item.category,
|
|
1320
1329
|
msg.references,
|
|
1321
1330
|
hasAttachment,
|
|
1322
1331
|
);
|
|
@@ -1400,6 +1409,10 @@ export class MessageSyncService {
|
|
|
1400
1409
|
* 3. Fall back to Message-ID (this message is a thread root)
|
|
1401
1410
|
*
|
|
1402
1411
|
* This ensures proper threading even when messages arrive out of order.
|
|
1412
|
+
*
|
|
1413
|
+
* `category` is the value the caller already holds from the Message it just
|
|
1414
|
+
* wrote, so the row starts out agreeing with it rather than defaulting to
|
|
1415
|
+
* `uncategorized` behind an already-classified message.
|
|
1403
1416
|
*/
|
|
1404
1417
|
private async createThreadForMessage(
|
|
1405
1418
|
threadMessageService: IThreadMessageRepository,
|
|
@@ -1412,6 +1425,7 @@ export class MessageSyncService {
|
|
|
1412
1425
|
sentDate: number,
|
|
1413
1426
|
envelope: ImapEnvelope,
|
|
1414
1427
|
flags: string[],
|
|
1428
|
+
category: ThreadMessageItem["category"],
|
|
1415
1429
|
references?: string[],
|
|
1416
1430
|
hasAttachment = false,
|
|
1417
1431
|
): Promise<void> {
|
|
@@ -1484,6 +1498,7 @@ export class MessageSyncService {
|
|
|
1484
1498
|
hasAttachment,
|
|
1485
1499
|
hasStars,
|
|
1486
1500
|
star: hasStars ? StarColor.Yellow : StarColor.None,
|
|
1501
|
+
category,
|
|
1487
1502
|
})
|
|
1488
1503
|
.catch((error: unknown) => {
|
|
1489
1504
|
// Ignore conflict errors (idempotent create)
|