@remit/mailbox-service 0.0.37 → 0.0.39
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/README.md +8 -7
- package/package.json +1 -1
- package/src/body-parse.ts +1 -1
- package/src/body-sync-placement-writeonce.test.ts +323 -0
- package/src/body-sync.ts +45 -7
- package/src/mailbox-cursor.ts +2 -2
- package/src/mailbox-management.ts +2 -2
- package/src/mailbox-queue.ts +1 -1
- package/src/message-move.ts +1 -1
- package/src/mime-walker.ts +3 -3
- package/src/pass-through-unit-of-work.ts +3 -3
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# @remit/mailbox-service
|
|
2
2
|
|
|
3
|
-
IMAP mailbox synchronization service for Remit. Provides connection management, mailbox discovery, and message sync
|
|
3
|
+
IMAP mailbox synchronization service for Remit. Provides connection management, mailbox discovery, and message sync. Persistence is injected: the service takes repository ports, never a database client.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **ImapFlow-based**: Modern async/await IMAP client with native envelope parsing
|
|
8
|
-
- **Mailbox Sync**: Discovers and syncs mailbox metadata from IMAP
|
|
8
|
+
- **Mailbox Sync**: Discovers and syncs mailbox metadata from IMAP into the mailbox repository
|
|
9
9
|
- **Message Sync**: Newest-first sync strategy with dual-watermark tracking
|
|
10
10
|
- **Address Extraction**: Parses and stores envelope addresses with role tracking
|
|
11
11
|
|
|
@@ -55,10 +55,11 @@ await connection.disconnect();
|
|
|
55
55
|
```typescript
|
|
56
56
|
import { MailboxSyncService } from "@remit/mailbox-service";
|
|
57
57
|
|
|
58
|
-
const syncService = new MailboxSyncService(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
const syncService = new MailboxSyncService(
|
|
59
|
+
mailboxRepository,
|
|
60
|
+
mailboxSpecialUseRepository,
|
|
61
|
+
logger,
|
|
62
|
+
);
|
|
62
63
|
|
|
63
64
|
const result = await syncService.syncMailboxes(
|
|
64
65
|
{ accountId: "acc-123" },
|
|
@@ -103,7 +104,7 @@ const synced = await messageSyncService.syncMessages(
|
|
|
103
104
|
|
|
104
105
|
| Export | Description |
|
|
105
106
|
| -------------------- | ----------------------------------------- |
|
|
106
|
-
| `MailboxSyncService` | Syncs mailbox list from IMAP
|
|
107
|
+
| `MailboxSyncService` | Syncs mailbox list from IMAP into the store |
|
|
107
108
|
| `MessageSyncService` | Syncs messages with newest-first strategy |
|
|
108
109
|
|
|
109
110
|
### Types
|
package/package.json
CHANGED
package/src/body-parse.ts
CHANGED
|
@@ -9,7 +9,7 @@ type FailureCode = QuarantineItem["failureCode"];
|
|
|
9
9
|
*
|
|
10
10
|
* This type is the whole reason the sync path can quarantine anything. Before
|
|
11
11
|
* it, every catch site on the body path saw one undifferentiated `unknown`
|
|
12
|
-
* covering mailparser, S3,
|
|
12
|
+
* covering mailparser, S3, the database and SQS alike, so "the message is built in
|
|
13
13
|
* a way Remit could not read" was indistinguishable from "S3 returned a 503".
|
|
14
14
|
* Recording the second as the first advances the cursor past mail that is
|
|
15
15
|
* perfectly fine and never fetches it again.
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 039 Non-goals / issue #383: placement is meant to run once per message.
|
|
3
|
+
* #378 (issue #355) guarded `Message.category` against the same two re-entrant
|
|
4
|
+
* paths re-running `applyPostStoreSteps` on an already-processed message —
|
|
5
|
+
* `fetchAndGetBody`'s `NoSuchKey` fallback and `syncBodies(..., force: true)`
|
|
6
|
+
* — but did not guard `resolvePlacement`/`computePlacement`. Without a guard,
|
|
7
|
+
* a message the provider originally junked and a user later rescued by hand
|
|
8
|
+
* (never touched by Remit, so `movedByRemit` records nothing) gets
|
|
9
|
+
* `classifyPlacement` re-evaluated against the same demote signals that
|
|
10
|
+
* junked it in the first place, and can be silently moved right back.
|
|
11
|
+
*
|
|
12
|
+
* Each "already decided" test below presets `placementDecidedAt` on the
|
|
13
|
+
* fixture (as a genuine first pass would have left it) and feeds the
|
|
14
|
+
* re-entrant pass a body whose headers WOULD trigger a confident demote if
|
|
15
|
+
* `classifyPlacement` ran fresh — so a regression that drops the guard shows
|
|
16
|
+
* up as an unwanted move, not as a passing test relying on deterministic
|
|
17
|
+
* heuristics happening to agree.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { Readable } from "node:stream";
|
|
22
|
+
import { describe, it } from "node:test";
|
|
23
|
+
import type {
|
|
24
|
+
AddressItem,
|
|
25
|
+
IAddressRepository,
|
|
26
|
+
IEnvelopeRepository,
|
|
27
|
+
IMailboxSpecialUseRepository,
|
|
28
|
+
IMessageRepository,
|
|
29
|
+
IThreadMessageRepository,
|
|
30
|
+
MessageItem,
|
|
31
|
+
UpdateMessageInput,
|
|
32
|
+
} from "@remit/data-ports";
|
|
33
|
+
import { MailboxSpecialUse } from "@remit/domain-enums";
|
|
34
|
+
import type { StorageService } from "@remit/storage-service";
|
|
35
|
+
import type { PlacementConfig } from "./body-sync.js";
|
|
36
|
+
import { BodySyncService } from "./body-sync.js";
|
|
37
|
+
import type { PlacementMoveService } from "./placement-move.js";
|
|
38
|
+
import type { IImapConnection } from "./types.js";
|
|
39
|
+
|
|
40
|
+
const MAILBOXES = {
|
|
41
|
+
inbox: { mailboxId: "mb-inbox", fullPath: "INBOX" },
|
|
42
|
+
junk: { mailboxId: "mb-junk", fullPath: "Junk" },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* DKIM signing domain mismatches the From domain, dmarc=fail, sender
|
|
47
|
+
* untrusted, a provider-spam header present (any value) — exactly the
|
|
48
|
+
* deterministic signal set `classifyPlacement`'s demote branch (inbox → junk,
|
|
49
|
+
* HIGH bar) confidently acts on when the message currently sits in Inbox.
|
|
50
|
+
*/
|
|
51
|
+
const DEMOTE_EML = Buffer.from(
|
|
52
|
+
[
|
|
53
|
+
"From: Support <support@evil-mimic.example>",
|
|
54
|
+
"To: me@example.com",
|
|
55
|
+
"Subject: Verify your account",
|
|
56
|
+
"Authentication-Results: mx.example.com; dmarc=fail",
|
|
57
|
+
"DKIM-Signature: v=1; a=rsa-sha256; d=relay.example.net; s=sel; b=xxx",
|
|
58
|
+
"X-Spam-Status: No, score=0.1",
|
|
59
|
+
"Content-Type: text/plain",
|
|
60
|
+
"",
|
|
61
|
+
"body",
|
|
62
|
+
].join("\r\n"),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
interface Harness {
|
|
66
|
+
service: BodySyncService;
|
|
67
|
+
message: MessageItem;
|
|
68
|
+
messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
|
|
69
|
+
moves: Array<{ messageId: string; destinationMailboxId: string }>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const buildHarness = (
|
|
73
|
+
message: Partial<MessageItem> & Pick<MessageItem, "messageId">,
|
|
74
|
+
retrieve: () => Promise<Buffer>,
|
|
75
|
+
): Harness => {
|
|
76
|
+
const messageUpdates: Array<{
|
|
77
|
+
messageId: string;
|
|
78
|
+
input: UpdateMessageInput;
|
|
79
|
+
}> = [];
|
|
80
|
+
const moves: Array<{ messageId: string; destinationMailboxId: string }> = [];
|
|
81
|
+
|
|
82
|
+
const messageRow = {
|
|
83
|
+
uid: 1,
|
|
84
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
85
|
+
...message,
|
|
86
|
+
} as unknown as MessageItem;
|
|
87
|
+
|
|
88
|
+
const messageService = {
|
|
89
|
+
get: async () => messageRow,
|
|
90
|
+
update: async (messageId: string, input: UpdateMessageInput) => {
|
|
91
|
+
messageUpdates.push({ messageId, input });
|
|
92
|
+
Object.assign(messageRow, input);
|
|
93
|
+
},
|
|
94
|
+
} as unknown as IMessageRepository;
|
|
95
|
+
|
|
96
|
+
const threadMessageService = {
|
|
97
|
+
findAllByMessageId: async () => [
|
|
98
|
+
{
|
|
99
|
+
threadMessageId: "tm-1",
|
|
100
|
+
messageId: message.messageId,
|
|
101
|
+
mailboxId: messageRow.mailboxId,
|
|
102
|
+
sentDate: 1,
|
|
103
|
+
isRead: false,
|
|
104
|
+
isDeleted: false,
|
|
105
|
+
hasStars: false,
|
|
106
|
+
hasAttachment: false,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
update: async () => {},
|
|
110
|
+
} as unknown as IThreadMessageRepository;
|
|
111
|
+
|
|
112
|
+
const storageService = {
|
|
113
|
+
retrieve,
|
|
114
|
+
storeMessageBody: async () => ({ uri: `s3://bodies/${message.messageId}` }),
|
|
115
|
+
storeMessageBodyStream: async () => ({
|
|
116
|
+
uri: `s3://bodies/${message.messageId}`,
|
|
117
|
+
}),
|
|
118
|
+
storeParsedBody: async () => {},
|
|
119
|
+
listBodyParts: async () => [],
|
|
120
|
+
} as unknown as StorageService;
|
|
121
|
+
|
|
122
|
+
const addressService = {
|
|
123
|
+
getAddress: async () => ({ flags: {} }) as unknown as AddressItem,
|
|
124
|
+
incrementInboundCount: async () => {},
|
|
125
|
+
} as unknown as IAddressRepository;
|
|
126
|
+
|
|
127
|
+
const envelopeService = {
|
|
128
|
+
listBodyParts: async () => [],
|
|
129
|
+
} as unknown as IEnvelopeRepository;
|
|
130
|
+
|
|
131
|
+
const mailboxSpecialUseService = {
|
|
132
|
+
findBySpecialUse: async (_accountId: string, specialUse: string) =>
|
|
133
|
+
specialUse === MailboxSpecialUse.Junk ? MAILBOXES.junk : null,
|
|
134
|
+
findInboxMailbox: async () => MAILBOXES.inbox,
|
|
135
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
136
|
+
|
|
137
|
+
const placementMoveService = {
|
|
138
|
+
moveMessage: async (
|
|
139
|
+
_accountConfigId: string,
|
|
140
|
+
messageId: string,
|
|
141
|
+
destinationMailboxId: string,
|
|
142
|
+
) => {
|
|
143
|
+
moves.push({ messageId, destinationMailboxId });
|
|
144
|
+
messageRow.mailboxId = destinationMailboxId;
|
|
145
|
+
},
|
|
146
|
+
} as unknown as PlacementMoveService;
|
|
147
|
+
|
|
148
|
+
const placementConfig: PlacementConfig = {
|
|
149
|
+
mailboxSpecialUseService,
|
|
150
|
+
placementMoveService,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const service = new BodySyncService(
|
|
154
|
+
messageService,
|
|
155
|
+
storageService,
|
|
156
|
+
threadMessageService,
|
|
157
|
+
addressService,
|
|
158
|
+
envelopeService,
|
|
159
|
+
{ info: () => {}, error: () => {} },
|
|
160
|
+
placementConfig,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
return { service, message: messageRow, messageUpdates, moves };
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const noSuchKeyError = () =>
|
|
167
|
+
Object.assign(new Error("missing"), { name: "NoSuchKey" });
|
|
168
|
+
|
|
169
|
+
describe("placement survives a re-entrant computePlacement pass (issue #383)", () => {
|
|
170
|
+
it("keeps a user-rescued message in Inbox through the NoSuchKey IMAP re-fetch", async () => {
|
|
171
|
+
const harness = buildHarness(
|
|
172
|
+
{
|
|
173
|
+
messageId: "m-1",
|
|
174
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
175
|
+
bodyStorageKey: "s3://bodies/m-1",
|
|
176
|
+
movedByRemit: false,
|
|
177
|
+
placementDecidedAt: 500,
|
|
178
|
+
},
|
|
179
|
+
async () => {
|
|
180
|
+
throw noSuchKeyError();
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const connection = {
|
|
185
|
+
openBox: async () => {},
|
|
186
|
+
fetchMessageBody: async () => DEMOTE_EML,
|
|
187
|
+
} as unknown as IImapConnection;
|
|
188
|
+
|
|
189
|
+
await harness.service.fetchAndGetBody(
|
|
190
|
+
"m-1",
|
|
191
|
+
"acc-1",
|
|
192
|
+
"cfg-1",
|
|
193
|
+
"INBOX",
|
|
194
|
+
async () => connection,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
assert.deepEqual(
|
|
198
|
+
harness.moves,
|
|
199
|
+
[],
|
|
200
|
+
"an already-decided placement must not be re-evaluated, even though these headers would confidently demote if classifyPlacement ran fresh",
|
|
201
|
+
);
|
|
202
|
+
assert.equal(harness.message.mailboxId, MAILBOXES.inbox.mailboxId);
|
|
203
|
+
assert.equal(harness.messageUpdates[0]?.input.movedByRemit, undefined);
|
|
204
|
+
assert.equal(harness.messageUpdates[0]?.input.placementVerdict, undefined);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("keeps a user-rescued message in Inbox when syncBodies re-fetches with force", async () => {
|
|
208
|
+
const harness = buildHarness(
|
|
209
|
+
{
|
|
210
|
+
messageId: "m-1",
|
|
211
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
212
|
+
bodyStorageKey: "s3://bodies/m-1",
|
|
213
|
+
movedByRemit: false,
|
|
214
|
+
placementDecidedAt: 500,
|
|
215
|
+
},
|
|
216
|
+
async () => {
|
|
217
|
+
throw new Error("force path must not retrieve from storage");
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const connection = {
|
|
222
|
+
openBox: async () => {},
|
|
223
|
+
async *fetchMessageBodies(uids: number[]) {
|
|
224
|
+
for (const uid of uids) {
|
|
225
|
+
yield { uid, source: Readable.from([DEMOTE_EML]) };
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
} as unknown as IImapConnection;
|
|
229
|
+
|
|
230
|
+
const result = await harness.service.syncBodies(
|
|
231
|
+
["m-1"],
|
|
232
|
+
"acc-1",
|
|
233
|
+
"cfg-1",
|
|
234
|
+
"INBOX",
|
|
235
|
+
async () => connection,
|
|
236
|
+
true,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
assert.deepEqual(result.syncedMessageIds, ["m-1"]);
|
|
240
|
+
assert.deepEqual(
|
|
241
|
+
harness.moves,
|
|
242
|
+
[],
|
|
243
|
+
"a forced re-sync must not re-decide an already-decided placement",
|
|
244
|
+
);
|
|
245
|
+
assert.equal(harness.message.mailboxId, MAILBOXES.inbox.mailboxId);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("still evaluates and can confidently move a message that has never been placement-classified", async () => {
|
|
249
|
+
const harness = buildHarness(
|
|
250
|
+
{
|
|
251
|
+
messageId: "m-1",
|
|
252
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
253
|
+
movedByRemit: false,
|
|
254
|
+
},
|
|
255
|
+
async () => {
|
|
256
|
+
throw new Error("no body stored yet; must not retrieve");
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const connection = {
|
|
261
|
+
openBox: async () => {},
|
|
262
|
+
fetchMessageBody: async () => DEMOTE_EML,
|
|
263
|
+
} as unknown as IImapConnection;
|
|
264
|
+
|
|
265
|
+
await harness.service.fetchAndGetBody(
|
|
266
|
+
"m-1",
|
|
267
|
+
"acc-1",
|
|
268
|
+
"cfg-1",
|
|
269
|
+
"INBOX",
|
|
270
|
+
async () => connection,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
assert.deepEqual(harness.moves, [
|
|
274
|
+
{ messageId: "m-1", destinationMailboxId: MAILBOXES.junk.mailboxId },
|
|
275
|
+
]);
|
|
276
|
+
assert.equal(harness.messageUpdates[0]?.input.movedByRemit, true);
|
|
277
|
+
assert.ok(
|
|
278
|
+
typeof harness.messageUpdates[0]?.input.placementDecidedAt === "number",
|
|
279
|
+
"a genuine first evaluation must record placementDecidedAt",
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("continues to protect a message Remit itself already moved (movedByRemit: true), unchanged behavior", async () => {
|
|
284
|
+
const harness = buildHarness(
|
|
285
|
+
{
|
|
286
|
+
messageId: "m-1",
|
|
287
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
288
|
+
bodyStorageKey: "s3://bodies/m-1",
|
|
289
|
+
movedByRemit: true,
|
|
290
|
+
// No placementDecidedAt — a legacy row synced before issue #383's
|
|
291
|
+
// guard existed. `classifyPlacement`'s own `movedByRemit` check
|
|
292
|
+
// must still hold on its own.
|
|
293
|
+
},
|
|
294
|
+
async () => {
|
|
295
|
+
throw noSuchKeyError();
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const connection = {
|
|
300
|
+
openBox: async () => {},
|
|
301
|
+
fetchMessageBody: async () => DEMOTE_EML,
|
|
302
|
+
} as unknown as IImapConnection;
|
|
303
|
+
|
|
304
|
+
await harness.service.fetchAndGetBody(
|
|
305
|
+
"m-1",
|
|
306
|
+
"acc-1",
|
|
307
|
+
"cfg-1",
|
|
308
|
+
"INBOX",
|
|
309
|
+
async () => connection,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
assert.deepEqual(
|
|
313
|
+
harness.moves,
|
|
314
|
+
[],
|
|
315
|
+
"movedByRemit must keep protecting a legacy row with no placementDecidedAt of its own",
|
|
316
|
+
);
|
|
317
|
+
assert.equal(harness.message.mailboxId, MAILBOXES.inbox.mailboxId);
|
|
318
|
+
assert.ok(
|
|
319
|
+
typeof harness.messageUpdates[0]?.input.placementDecidedAt === "number",
|
|
320
|
+
"the legacy row is backfilled with placementDecidedAt going forward, self-healing for future passes",
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
});
|
package/src/body-sync.ts
CHANGED
|
@@ -74,6 +74,7 @@ type ThreadMessageCategory = ThreadMessageItem["category"];
|
|
|
74
74
|
interface PlacementOutcome {
|
|
75
75
|
verdict?: MessagePlacementVerdict;
|
|
76
76
|
move?: { destinationMailboxId: string; destinationPath: string };
|
|
77
|
+
placementDecidedAt?: number;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
/**
|
|
@@ -185,6 +186,19 @@ const hasDecidedCategory = (
|
|
|
185
186
|
): boolean =>
|
|
186
187
|
category !== undefined && category !== MessageCategory.uncategorized;
|
|
187
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Issue #383 (RFC 039 Non-goals): whether {@link BodySyncService.computePlacement}
|
|
191
|
+
* has already produced a verdict for this message — moved, left in place, or
|
|
192
|
+
* archived, confident or unsure alike. Absence means placement has genuinely
|
|
193
|
+
* never been evaluated. The same two re-entrant paths `hasDecidedCategory`
|
|
194
|
+
* guards (`fetchAndGetBody`'s `NoSuchKey` fallback, `syncBodies(..., force:
|
|
195
|
+
* true)`) also re-enter `computePlacement`; without this guard a message a
|
|
196
|
+
* user manually rescued (never touched by Remit, so `movedByRemit` never
|
|
197
|
+
* recorded anything) can be silently re-evaluated and moved right back.
|
|
198
|
+
*/
|
|
199
|
+
const hasDecidedPlacement = (placementDecidedAt: number | undefined): boolean =>
|
|
200
|
+
placementDecidedAt !== undefined;
|
|
201
|
+
|
|
188
202
|
export const toParsedBody = (parsed: ParsedMail): ParsedBody => ({
|
|
189
203
|
text: parsed.text ?? null,
|
|
190
204
|
html: typeof parsed.html === "string" ? parsed.html : null,
|
|
@@ -867,6 +881,11 @@ export class BodySyncService {
|
|
|
867
881
|
// this guard a re-entrant pass would let a *later* override silently
|
|
868
882
|
// rewrite a category already decided on an earlier message, which is
|
|
869
883
|
// exactly the churn RFC 030's GSI-safety argument forbids.
|
|
884
|
+
//
|
|
885
|
+
// `resolved.placementDecidedAt` (issue #383) guards the same two
|
|
886
|
+
// re-entrant paths for placement: `computePlacement` already declined to
|
|
887
|
+
// recompute a verdict once this field is set, so it is only ever present
|
|
888
|
+
// here on the pass that first decided it.
|
|
870
889
|
const existingMessage = await this.messageService.get(messageId);
|
|
871
890
|
const finalCategory = hasDecidedCategory(existingMessage.category)
|
|
872
891
|
? existingMessage.category
|
|
@@ -889,6 +908,9 @@ export class BodySyncService {
|
|
|
889
908
|
...(moved ? { movedByRemit: true } : {}),
|
|
890
909
|
...(resolved.verdict ? { placementVerdict: resolved.verdict } : {}),
|
|
891
910
|
...(filterMove ? { filterMove } : {}),
|
|
911
|
+
...(resolved.placementDecidedAt
|
|
912
|
+
? { placementDecidedAt: resolved.placementDecidedAt }
|
|
913
|
+
: {}),
|
|
892
914
|
};
|
|
893
915
|
await this.messageService.update(messageId, update);
|
|
894
916
|
this.log.info({ messageId, storageKey: bodyRef.uri }, "Body stored");
|
|
@@ -1320,8 +1342,11 @@ export class BodySyncService {
|
|
|
1320
1342
|
*
|
|
1321
1343
|
* Returns a {@link PlacementOutcome}: a `verdict` to persist whenever Remit
|
|
1322
1344
|
* decided to act (action != leave), confident and unsure alike, so the
|
|
1323
|
-
* distribution is queryable on the message;
|
|
1324
|
-
* a
|
|
1345
|
+
* distribution is queryable on the message; a `move` to enqueue only for a
|
|
1346
|
+
* confident verdict; and `placementDecidedAt` whenever a verdict — including
|
|
1347
|
+
* "leave" — was genuinely computed (issue #383), so a re-entrant call short-
|
|
1348
|
+
* circuits instead of re-deciding a placement the user may have since
|
|
1349
|
+
* overridden by hand.
|
|
1325
1350
|
*
|
|
1326
1351
|
* Always logs a structured verdict line for confident, actionable verdicts so
|
|
1327
1352
|
* the real distribution is observable on a live mailbox.
|
|
@@ -1334,7 +1359,7 @@ export class BodySyncService {
|
|
|
1334
1359
|
* alertable field instead of failing the surrounding message store. The empty
|
|
1335
1360
|
* {@link PlacementOutcome} means "no action", whether Remit genuinely decided
|
|
1336
1361
|
* to leave the message alone or placement itself failed; the alert log is
|
|
1337
|
-
* what distinguishes the latter.
|
|
1362
|
+
* what distinguishes the latter, and neither case marks the message decided.
|
|
1338
1363
|
*/
|
|
1339
1364
|
private async resolvePlacement(
|
|
1340
1365
|
messageId: string,
|
|
@@ -1378,6 +1403,16 @@ export class BodySyncService {
|
|
|
1378
1403
|
const { mailboxSpecialUseService } = placementConfig;
|
|
1379
1404
|
|
|
1380
1405
|
const message = await this.messageService.get(messageId);
|
|
1406
|
+
|
|
1407
|
+
// Issue #383: placement is meant to run once per message (RFC 039
|
|
1408
|
+
// Non-goals). Once a verdict has EVER been decided for this message —
|
|
1409
|
+
// moved, left in place, or archived — a re-entrant pass (the `NoSuchKey`
|
|
1410
|
+
// fallback in `fetchAndGetBody`, `syncBodies(..., force: true)`) must not
|
|
1411
|
+
// recompute it: a message a user has since moved by hand (which never
|
|
1412
|
+
// touches `movedByRemit`) would otherwise be silently re-evaluated
|
|
1413
|
+
// against the same signals that placed it in the first place.
|
|
1414
|
+
if (hasDecidedPlacement(message.placementDecidedAt)) return {};
|
|
1415
|
+
|
|
1381
1416
|
const junkMailbox = await mailboxSpecialUseService.findBySpecialUse(
|
|
1382
1417
|
accountId,
|
|
1383
1418
|
MailboxSpecialUse.Junk,
|
|
@@ -1421,14 +1456,16 @@ export class BodySyncService {
|
|
|
1421
1456
|
// audit record of its own. `flags.autoArchive` (issue #300) is a distinct,
|
|
1422
1457
|
// lower-priority filing preference: it only files a message away when
|
|
1423
1458
|
// `blocked`/DKIM/DMARC had nothing to say, never overriding a confident
|
|
1424
|
-
// junk/inbox verdict computed above.
|
|
1459
|
+
// junk/inbox verdict computed above. Still marked decided (issue #383):
|
|
1460
|
+
// "leave" is itself a verdict, not "not yet evaluated".
|
|
1425
1461
|
if (verdict.action === "leave") {
|
|
1426
|
-
|
|
1462
|
+
const outcome = await this.resolveAutoArchive(
|
|
1427
1463
|
mailboxSpecialUseService,
|
|
1428
1464
|
message,
|
|
1429
1465
|
accountId,
|
|
1430
1466
|
signals.autoArchive,
|
|
1431
1467
|
);
|
|
1468
|
+
return { ...outcome, placementDecidedAt: Date.now() };
|
|
1432
1469
|
}
|
|
1433
1470
|
|
|
1434
1471
|
// Audit verdict — recorded for every actionable verdict (both
|
|
@@ -1452,12 +1489,12 @@ export class BodySyncService {
|
|
|
1452
1489
|
// Only a confident verdict moves mail. An unsure verdict is recorded
|
|
1453
1490
|
// but never enqueues a move.
|
|
1454
1491
|
if (verdict.confidence !== "confident") {
|
|
1455
|
-
return { verdict: audit };
|
|
1492
|
+
return { verdict: audit, placementDecidedAt: audit.decidedAt };
|
|
1456
1493
|
}
|
|
1457
1494
|
|
|
1458
1495
|
const target =
|
|
1459
1496
|
verdict.action === "move-to-inbox" ? inboxMailbox : junkMailbox;
|
|
1460
|
-
if (!target) return { verdict: audit };
|
|
1497
|
+
if (!target) return { verdict: audit, placementDecidedAt: audit.decidedAt };
|
|
1461
1498
|
|
|
1462
1499
|
// Structured verdict line — emitted for confident, actionable verdicts
|
|
1463
1500
|
// so the real verdict distribution is observable on a live mailbox.
|
|
@@ -1480,6 +1517,7 @@ export class BodySyncService {
|
|
|
1480
1517
|
destinationMailboxId: target.mailboxId,
|
|
1481
1518
|
destinationPath: target.fullPath,
|
|
1482
1519
|
},
|
|
1520
|
+
placementDecidedAt: audit.decidedAt,
|
|
1483
1521
|
};
|
|
1484
1522
|
}
|
|
1485
1523
|
|
package/src/mailbox-cursor.ts
CHANGED
|
@@ -33,8 +33,8 @@ export interface MailboxCursorGuardDeps {
|
|
|
33
33
|
/**
|
|
34
34
|
* `cursorState` is total per RFC 032 (defaults to `normal`) — but that default
|
|
35
35
|
* only applies to rows written after this field existed. A row persisted
|
|
36
|
-
* before this migration (
|
|
37
|
-
*
|
|
36
|
+
* before this migration (the column genuinely NULL) reads back absent
|
|
37
|
+
* despite the type
|
|
38
38
|
* saying otherwise, so every consumer here treats `undefined` the same as
|
|
39
39
|
* `normal` defensively rather than trusting the type.
|
|
40
40
|
*/
|
|
@@ -100,7 +100,7 @@ export const validateMailboxOperation = (
|
|
|
100
100
|
* Service for managing mailbox operations (create, rename, delete).
|
|
101
101
|
*
|
|
102
102
|
* Implements an optimistic local-first pattern:
|
|
103
|
-
* 1. Updates are applied locally first
|
|
103
|
+
* 1. Updates are applied locally first
|
|
104
104
|
* 2. Changes are queued for IMAP sync via SQS
|
|
105
105
|
* 3. Worker processes queue and syncs to IMAP server
|
|
106
106
|
*/
|
|
@@ -244,7 +244,7 @@ export class MailboxManagementService {
|
|
|
244
244
|
|
|
245
245
|
this.log.info({ mailboxId, path }, "Deleted mailbox on IMAP server");
|
|
246
246
|
|
|
247
|
-
// Delete the mailbox entity from
|
|
247
|
+
// Delete the mailbox entity from the local store
|
|
248
248
|
await this.mailboxService.delete(accountId, mailboxId);
|
|
249
249
|
|
|
250
250
|
return { success: true };
|
package/src/mailbox-queue.ts
CHANGED
|
@@ -83,7 +83,7 @@ export interface MailboxQueueConfig {
|
|
|
83
83
|
* Service for mailbox management with automatic IMAP sync queueing.
|
|
84
84
|
*
|
|
85
85
|
* Implements optimistic local-first pattern:
|
|
86
|
-
* 1. Updates local
|
|
86
|
+
* 1. Updates local state immediately
|
|
87
87
|
* 2. Enqueues mailbox management event to SQS for worker to sync to IMAP
|
|
88
88
|
*
|
|
89
89
|
* This follows the same pattern as FlagQueueService (RFC 014).
|
package/src/message-move.ts
CHANGED
|
@@ -115,7 +115,7 @@ export interface DeleteOptions {
|
|
|
115
115
|
* Service for moving, copying, and deleting messages.
|
|
116
116
|
*
|
|
117
117
|
* Implements optimistic local-first pattern:
|
|
118
|
-
* 1. Updates local
|
|
118
|
+
* 1. Updates local state immediately (Message + ThreadMessage)
|
|
119
119
|
* 2. Enqueues event to SQS for worker to sync to IMAP
|
|
120
120
|
*
|
|
121
121
|
* Following RFC 016 for message deletion and moving.
|
package/src/mime-walker.ts
CHANGED
|
@@ -266,8 +266,8 @@ const toRecord = (
|
|
|
266
266
|
* **Part-path uniqueness**: some IMAP servers (and the `message/rfc822`
|
|
267
267
|
* inner-body convention) return child nodes with an empty `part` field.
|
|
268
268
|
* Assigning ROOT_PART_PATH to every such node would produce duplicate keys
|
|
269
|
-
* and
|
|
270
|
-
*
|
|
269
|
+
* and make `upsertBodyParts` write the same body part twice. Non-root nodes
|
|
270
|
+
* without a `part` therefore receive a
|
|
271
271
|
* synthetic path `<parentPath>.<siblingIndex>` that is stable across
|
|
272
272
|
* repeated syncs of the same message.
|
|
273
273
|
*/
|
|
@@ -287,7 +287,7 @@ export const walkMimeStructure = (root: MimeNode): BodyPartRecord[] => {
|
|
|
287
287
|
partPath = ROOT_PART_PATH;
|
|
288
288
|
} else {
|
|
289
289
|
// Non-root node without an IMAP part path — synthesise one so
|
|
290
|
-
// the
|
|
290
|
+
// the body-part keys remain unique. This happens most commonly
|
|
291
291
|
// for the body of a message/rfc822 attachment, whose inner
|
|
292
292
|
// structure imapflow attaches as a childNode with part="".
|
|
293
293
|
partPath = `${parentPath}.${siblingIndex}`;
|
|
@@ -2,9 +2,9 @@ import type { IUnitOfWork, UnitOfWorkRepositories } from "@remit/data-ports";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Runs the write set against fixed repositories with no surrounding
|
|
5
|
-
* transaction. For
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* transaction: the writes are not atomic. For a backend that has no
|
|
6
|
+
* cross-entity transaction this matches its own guarantees; the SQLite path
|
|
7
|
+
* injects a real transactional unit of work instead.
|
|
8
8
|
*/
|
|
9
9
|
export class PassThroughUnitOfWork implements IUnitOfWork {
|
|
10
10
|
constructor(private repos: UnitOfWorkRepositories) {}
|