@remit/mailbox-service 0.0.110 → 0.0.112

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 (123) hide show
  1. package/package.json +21 -6
  2. package/src/body-part-mapper.ts +51 -1
  3. package/src/body-sync-queue.test.ts +39 -99
  4. package/src/body-sync-terminal.test.ts +115 -277
  5. package/src/body-sync-terminal.ts +10 -12
  6. package/src/body-sync.test.ts +925 -0
  7. package/src/body-sync.ts +226 -200
  8. package/src/classification-backfill.test.ts +197 -527
  9. package/src/classify-message.test.ts +11 -74
  10. package/src/external-move.test.ts +102 -209
  11. package/src/external-move.ts +13 -0
  12. package/src/filter-operations.test.ts +319 -0
  13. package/src/filter-operations.ts +377 -0
  14. package/src/filter-update.test.ts +313 -0
  15. package/src/filters/filter-config.test.ts +55 -197
  16. package/src/filters/filter-config.ts +4 -39
  17. package/src/filters/match.test.ts +1 -38
  18. package/src/filters/match.ts +3 -52
  19. package/src/filters/pipeline.test.ts +158 -289
  20. package/src/filters/pipeline.ts +56 -170
  21. package/src/flag-push-terminal.ts +9 -11
  22. package/src/flag-push.test.ts +76 -372
  23. package/src/flag-queue.test.ts +197 -0
  24. package/src/folder-operations.test.ts +273 -0
  25. package/src/folder-operations.ts +217 -0
  26. package/src/forwarded-message.test.ts +122 -0
  27. package/src/imapflow-connect-retry.test.ts +76 -113
  28. package/src/imapflow-connection.test.ts +34 -0
  29. package/src/imapflow-connection.ts +19 -15
  30. package/src/index.ts +59 -12
  31. package/src/list-id-backfill.test.ts +133 -499
  32. package/src/mailbox-cursor.test.ts +81 -189
  33. package/src/mailbox-delete-intent.test.ts +78 -205
  34. package/src/mailbox-intent.ts +5 -3
  35. package/src/mailbox-management.test.ts +107 -220
  36. package/src/mailbox-management.ts +1 -2
  37. package/src/mailbox-presence.test.ts +36 -34
  38. package/src/mailbox-queue.ts +2 -20
  39. package/src/mailbox-rename-intent.test.ts +156 -325
  40. package/src/mailbox-rename-subtree.test.ts +205 -582
  41. package/src/mailbox-sync.test.ts +182 -744
  42. package/src/message-move.test.ts +726 -0
  43. package/src/message-move.ts +227 -57
  44. package/src/message-read.ts +87 -0
  45. package/src/message-save-retry.test.ts +90 -0
  46. package/src/message-sync-flags.test.ts +10 -1
  47. package/src/message-sync.test.ts +762 -0
  48. package/src/message-sync.ts +409 -239
  49. package/src/mime-walker.ts +16 -15
  50. package/src/organize-job.ts +93 -0
  51. package/src/outbox-attachment.test.ts +97 -209
  52. package/src/outbox-failed-recovery.test.ts +198 -269
  53. package/src/outbox-queue.test.ts +24 -0
  54. package/src/outbox-queue.ts +25 -8
  55. package/src/ownership.ts +140 -0
  56. package/src/placement-move-terminal.ts +9 -12
  57. package/src/placement-move.ts +33 -6
  58. package/src/placement-settled.ts +28 -4
  59. package/src/placement.test.ts +404 -0
  60. package/src/quarantine.test.ts +90 -148
  61. package/src/quarantine.ts +3 -2
  62. package/src/refusal.ts +29 -0
  63. package/src/restore-source-placement.ts +0 -4
  64. package/src/spam-report.test.ts +238 -710
  65. package/src/sync-warning.test.ts +213 -0
  66. package/src/sync-warning.ts +56 -0
  67. package/src/test-helpers/fake-imap.ts +690 -0
  68. package/src/test-helpers/test-queue.ts +56 -0
  69. package/src/test-helpers/test-store.ts +299 -0
  70. package/src/thread-root.test.ts +29 -0
  71. package/src/thread-root.ts +51 -0
  72. package/src/types/mailparser-augment.d.ts +8 -0
  73. package/src/types.ts +1 -1
  74. package/src/adversarial-mime.e2e.test.ts +0 -307
  75. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  76. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  77. package/src/body-sync-category-denormalize.test.ts +0 -352
  78. package/src/body-sync-category-override.test.ts +0 -501
  79. package/src/body-sync-category-writeonce.test.ts +0 -234
  80. package/src/body-sync-filter.test.ts +0 -237
  81. package/src/body-sync-placement-flags.test.ts +0 -347
  82. package/src/body-sync-placement-writeonce.test.ts +0 -337
  83. package/src/body-sync-quarantine.test.ts +0 -276
  84. package/src/body-sync-resync.test.ts +0 -605
  85. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  86. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  87. package/src/filters/anchor-drift.ts +0 -55
  88. package/src/filters/message-embedder.test.ts +0 -39
  89. package/src/filters/message-embedder.ts +0 -33
  90. package/src/flag-push-terminal.test.ts +0 -209
  91. package/src/flag-queue-stars.test.ts +0 -171
  92. package/src/imapflow-connection.e2e.test.ts +0 -335
  93. package/src/imapflow-connection.integ.test.ts +0 -316
  94. package/src/message-delete-junk-reconcile.test.ts +0 -117
  95. package/src/message-delete-no-trash.test.ts +0 -245
  96. package/src/message-delete-unsettled-placement.test.ts +0 -396
  97. package/src/message-move-copy.test.ts +0 -562
  98. package/src/message-move-empty-trash.test.ts +0 -326
  99. package/src/message-move-same-mailbox.test.ts +0 -139
  100. package/src/message-move-unsettled-placement.test.ts +0 -378
  101. package/src/message-sync-address-spoof.test.ts +0 -293
  102. package/src/message-sync-category.test.ts +0 -142
  103. package/src/message-sync-changedsince.test.ts +0 -840
  104. package/src/message-sync-junk-harvest.test.ts +0 -244
  105. package/src/message-sync-quarantine.test.ts +0 -416
  106. package/src/message-sync-repoint.test.ts +0 -419
  107. package/src/outbox-queue-settled-row.test.ts +0 -140
  108. package/src/pass-through-unit-of-work.ts +0 -17
  109. package/src/placement-move-terminal.test.ts +0 -300
  110. package/src/placement-move-unsettled.test.ts +0 -275
  111. package/src/placement-transition-race.test.ts +0 -482
  112. package/src/stale-message-reconcile.test.ts +0 -82
  113. package/src/stale-message-reconcile.ts +0 -55
  114. package/src/test-helpers/isolated-mailbox.ts +0 -44
  115. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
@@ -0,0 +1,213 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import type {
4
+ AccountItem,
5
+ ISyncWarningRepository,
6
+ MailboxItem,
7
+ } from "@remit/data-ports";
8
+ import { BodySyncService } from "./body-sync.js";
9
+ import { MessageSyncService } from "./message-sync.js";
10
+ import { SyncWarningService } from "./sync-warning.js";
11
+ import {
12
+ eml,
13
+ type FakeImapConnection,
14
+ FakeImapServer,
15
+ } from "./test-helpers/fake-imap.js";
16
+ import {
17
+ messageAtUid,
18
+ openStore,
19
+ type Store,
20
+ seedAccount,
21
+ seedMailbox,
22
+ silentLog,
23
+ syncFolder,
24
+ } from "./test-helpers/test-store.js";
25
+
26
+ let store: Store;
27
+ let imap: FakeImapServer;
28
+ let connection: FakeImapConnection;
29
+ let account: AccountItem;
30
+ let inbox: MailboxItem;
31
+
32
+ beforeEach(async () => {
33
+ store = await openStore();
34
+ imap = new FakeImapServer();
35
+ account = await seedAccount(store);
36
+ inbox = await seedMailbox(store, account, "INBOX");
37
+ imap.addFolder("INBOX");
38
+ connection = imap.connection();
39
+ });
40
+
41
+ afterEach(async () => {
42
+ await store.close();
43
+ });
44
+
45
+ const syncHeaders = (
46
+ syncWarnings: ISyncWarningRepository = store.syncWarning,
47
+ ) =>
48
+ new MessageSyncService(
49
+ { getConnection: () => connection, close: async () => {} },
50
+ store.mailbox,
51
+ store.mailboxSpecialUse,
52
+ store.message,
53
+ store.envelope,
54
+ store.address,
55
+ store.threadMessage,
56
+ new SyncWarningService(syncWarnings),
57
+ silentLog,
58
+ store.flagPush,
59
+ store.messageFlag,
60
+ ).syncMessages(inbox.mailboxId, account.accountId, account.accountConfigId);
61
+
62
+ const syncBody = (messageId: string, source: FakeImapConnection) =>
63
+ new BodySyncService(
64
+ store.message,
65
+ store.storage(),
66
+ store.threadMessage,
67
+ store.address,
68
+ store.envelope,
69
+ new SyncWarningService(store.syncWarning),
70
+ silentLog,
71
+ ).syncBodies(
72
+ [messageId],
73
+ account.accountId,
74
+ account.accountConfigId,
75
+ {
76
+ mailboxId: inbox.mailboxId,
77
+ fullPath: "INBOX",
78
+ placement: "inbox",
79
+ uidValidity: inbox.uidValidity,
80
+ },
81
+ async () => source,
82
+ );
83
+
84
+ const warnings = async () =>
85
+ (await store.syncWarning.listByMailboxId(inbox.mailboxId)).map(
86
+ ({ uid, stage, attempts }) => ({ uid, stage, attempts }),
87
+ );
88
+
89
+ const servedUidValidity = async (): Promise<number> =>
90
+ (await imap.connection().openBox("INBOX")).uidvalidity;
91
+
92
+ const plant = (uid: number, uidValidity: number) =>
93
+ store.syncWarning.recordFailures([
94
+ {
95
+ accountConfigId: account.accountConfigId,
96
+ accountId: account.accountId,
97
+ mailboxId: inbox.mailboxId,
98
+ uidValidity,
99
+ uid,
100
+ stage: "Header",
101
+ },
102
+ ]);
103
+
104
+ const dropEnvelopes = () => {
105
+ const fetch = connection.fetchMessages;
106
+ connection.fetchMessages = async (uids) =>
107
+ (await fetch(uids)).map((row) => ({ ...row, envelope: undefined }));
108
+ return () => {
109
+ connection.fetchMessages = fetch;
110
+ };
111
+ };
112
+
113
+ describe("a message whose headers fail to sync", () => {
114
+ it("keeps one warning that counts every failed round", async () => {
115
+ const uid = imap.deliver("INBOX", eml({ "Message-ID": "<a@example.com>" }));
116
+ dropEnvelopes();
117
+
118
+ for (let round = 0; round < 3; round++) await syncHeaders();
119
+
120
+ assert.deepEqual(await warnings(), [{ uid, stage: "Header", attempts: 3 }]);
121
+ });
122
+
123
+ it("drops the warning once the message syncs", async () => {
124
+ imap.deliver("INBOX", eml({ "Message-ID": "<a@example.com>" }));
125
+ const restore = dropEnvelopes();
126
+ await syncHeaders();
127
+ restore();
128
+
129
+ await syncHeaders();
130
+
131
+ assert.deepEqual(await warnings(), []);
132
+ });
133
+
134
+ it("fails the round when the warning cannot be written", async () => {
135
+ imap.deliver("INBOX", eml({ "Message-ID": "<a@example.com>" }));
136
+ dropEnvelopes();
137
+ const failing: ISyncWarningRepository = {
138
+ ...store.syncWarning,
139
+ recordFailures: async () => {
140
+ throw new Error("database is locked");
141
+ },
142
+ };
143
+
144
+ await assert.rejects(syncHeaders(failing), /database is locked/);
145
+ });
146
+
147
+ it("drops a warning from an earlier UIDVALIDITY", async () => {
148
+ imap.deliver("INBOX", eml({ "Message-ID": "<a@example.com>" }));
149
+ const uidValidity = await servedUidValidity();
150
+ await plant(1, uidValidity + 1);
151
+
152
+ await syncHeaders();
153
+
154
+ assert.deepEqual(await warnings(), []);
155
+ });
156
+
157
+ it("drops a warning for a uid the server no longer lists", async () => {
158
+ const uidValidity = await servedUidValidity();
159
+ await plant(40217, uidValidity);
160
+
161
+ await syncHeaders();
162
+
163
+ assert.deepEqual(await warnings(), []);
164
+ });
165
+
166
+ it("goes with its mailbox", async () => {
167
+ imap.deliver("INBOX", eml({ "Message-ID": "<a@example.com>" }));
168
+ dropEnvelopes();
169
+ await syncHeaders();
170
+
171
+ await store.mailbox.delete(account.accountId, inbox.mailboxId);
172
+
173
+ assert.deepEqual(await warnings(), []);
174
+ });
175
+ });
176
+
177
+ describe("a message whose body fails to sync", () => {
178
+ it("drops the warning once a header round finds the message gone from the server", async () => {
179
+ const uid = imap.deliver("INBOX", eml({ "Message-ID": "<c@example.com>" }));
180
+ await syncFolder(store, account, inbox, imap);
181
+ const { messageId } = await messageAtUid(store, inbox, uid);
182
+ const silent = imap.connection();
183
+ silent.fetchMessageBodies = async function* () {
184
+ yield* [];
185
+ };
186
+ for (let round = 0; round < 3; round++) await syncBody(messageId, silent);
187
+ assert.deepEqual(await warnings(), [{ uid, stage: "Body", attempts: 3 }]);
188
+
189
+ imap.expunge("INBOX", uid);
190
+ await syncHeaders();
191
+
192
+ assert.deepEqual(await warnings(), []);
193
+ });
194
+
195
+ it("keeps one warning per failed round and drops it once the body is stored", async () => {
196
+ const uid = imap.deliver("INBOX", eml({ "Message-ID": "<b@example.com>" }));
197
+ await syncFolder(store, account, inbox, imap);
198
+ const { messageId } = await messageAtUid(store, inbox, uid);
199
+ const silent = imap.connection();
200
+ silent.fetchMessageBodies = async function* () {
201
+ yield* [];
202
+ };
203
+
204
+ for (let round = 0; round < 3; round++) await syncBody(messageId, silent);
205
+
206
+ assert.deepEqual(await warnings(), [{ uid, stage: "Body", attempts: 3 }]);
207
+
208
+ const result = await syncBody(messageId, imap.connection());
209
+
210
+ assert.deepEqual(result.failedMessageIds, []);
211
+ assert.deepEqual(await warnings(), []);
212
+ });
213
+ });
@@ -0,0 +1,56 @@
1
+ import type {
2
+ ISyncWarningRepository,
3
+ SyncWarningItem,
4
+ SyncWarningKey,
5
+ } from "@remit/data-ports";
6
+
7
+ export type SyncWarningContext = Omit<SyncWarningKey, "uid" | "stage">;
8
+
9
+ export type ServerUids =
10
+ | { kind: "Listed"; uids: readonly number[] }
11
+ | { kind: "OnRequest"; list: () => Promise<readonly number[]> }
12
+ | { kind: "Unlisted" };
13
+
14
+ export interface SyncWarningRound {
15
+ failedUids: readonly number[];
16
+ syncedUids: readonly number[];
17
+ serverUids: ServerUids;
18
+ }
19
+
20
+ const listedUids = async (
21
+ serverUids: ServerUids,
22
+ recordedCount: number,
23
+ ): Promise<Set<number> | undefined> => {
24
+ if (serverUids.kind === "Listed") return new Set(serverUids.uids);
25
+ if (serverUids.kind === "OnRequest" && recordedCount > 0) {
26
+ return new Set(await serverUids.list());
27
+ }
28
+ return undefined;
29
+ };
30
+
31
+ export class SyncWarningService {
32
+ constructor(private readonly repository: ISyncWarningRepository) {}
33
+
34
+ async settle(
35
+ context: SyncWarningContext,
36
+ stage: SyncWarningItem["stage"],
37
+ round: SyncWarningRound,
38
+ ): Promise<void> {
39
+ await this.repository.recordFailures(
40
+ [...new Set(round.failedUids)].map((uid) => ({ ...context, uid, stage })),
41
+ );
42
+
43
+ const recorded = await this.repository.listByMailboxId(context.mailboxId);
44
+ const synced = new Set(round.syncedUids);
45
+ const listed = await listedUids(round.serverUids, recorded.length);
46
+ const cleared = recorded
47
+ .filter(
48
+ (row) =>
49
+ (listed !== undefined && !listed.has(row.uid)) ||
50
+ (row.stage === stage &&
51
+ (row.uidValidity !== context.uidValidity || synced.has(row.uid))),
52
+ )
53
+ .map((row) => row.syncWarningId);
54
+ await this.repository.deleteMany(cleared);
55
+ }
56
+ }