@remit/mailbox-service 0.0.112 → 0.0.114

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 (270) hide show
  1. package/dist/account-credentials.d.ts +83 -0
  2. package/dist/account-credentials.js +104 -0
  3. package/dist/attribute-mapper.d.ts +36 -0
  4. package/dist/attribute-mapper.js +119 -0
  5. package/dist/body-parse.d.ts +28 -0
  6. package/{src/body-parse.ts → dist/body-parse.js} +18 -26
  7. package/dist/body-part-mapper.d.ts +92 -0
  8. package/dist/body-part-mapper.js +301 -0
  9. package/dist/body-sync-queue.d.ts +59 -0
  10. package/dist/body-sync-queue.js +56 -0
  11. package/dist/body-sync-terminal.d.ts +58 -0
  12. package/dist/body-sync-terminal.js +95 -0
  13. package/dist/body-sync.d.ts +351 -0
  14. package/dist/body-sync.js +1204 -0
  15. package/dist/calendar-parts.d.ts +23 -0
  16. package/dist/calendar-parts.js +18 -0
  17. package/dist/classification-backfill.d.ts +91 -0
  18. package/dist/classification-backfill.js +180 -0
  19. package/dist/classify-message.d.ts +31 -0
  20. package/dist/classify-message.js +89 -0
  21. package/dist/connection-factory.d.ts +54 -0
  22. package/dist/connection-factory.js +54 -0
  23. package/dist/connection-test.d.ts +41 -0
  24. package/dist/connection-test.js +152 -0
  25. package/dist/external-move.d.ts +94 -0
  26. package/dist/external-move.js +173 -0
  27. package/dist/filter-operations.d.ts +42 -0
  28. package/dist/filter-operations.js +211 -0
  29. package/dist/filters/filter-config.d.ts +14 -0
  30. package/dist/filters/filter-config.js +10 -0
  31. package/{src/filters/list-id.ts → dist/filters/list-id.d.ts} +2 -16
  32. package/dist/filters/list-id.js +26 -0
  33. package/dist/filters/match.d.ts +69 -0
  34. package/dist/filters/match.js +165 -0
  35. package/dist/filters/pipeline.d.ts +64 -0
  36. package/dist/filters/pipeline.js +100 -0
  37. package/{src/flag-push-terminal.ts → dist/flag-push-terminal.d.ts} +16 -72
  38. package/dist/flag-push-terminal.js +63 -0
  39. package/dist/flag-push.d.ts +69 -0
  40. package/dist/flag-push.js +111 -0
  41. package/dist/flag-queue.d.ts +175 -0
  42. package/dist/flag-queue.js +291 -0
  43. package/dist/folder-operations.d.ts +39 -0
  44. package/dist/folder-operations.js +112 -0
  45. package/dist/folder-placement.d.ts +13 -0
  46. package/dist/folder-placement.js +23 -0
  47. package/dist/heuristics/authenticationResults.d.ts +10 -0
  48. package/dist/heuristics/authenticationResults.js +187 -0
  49. package/dist/heuristics/classifyByHeaders.d.ts +82 -0
  50. package/dist/heuristics/classifyByHeaders.js +365 -0
  51. package/dist/heuristics/classifyPlacement.d.ts +67 -0
  52. package/dist/heuristics/classifyPlacement.js +153 -0
  53. package/dist/heuristics/machineSenders.d.ts +21 -0
  54. package/{src/heuristics/machineSenders.ts → dist/heuristics/machineSenders.js} +34 -40
  55. package/dist/heuristics/senderMismatch.d.ts +81 -0
  56. package/dist/heuristics/senderMismatch.js +192 -0
  57. package/dist/heuristics/socialDomains.d.ts +14 -0
  58. package/{src/heuristics/socialDomains.ts → dist/heuristics/socialDomains.js} +9 -9
  59. package/dist/heuristics/transactionalDomains.d.ts +18 -0
  60. package/{src/heuristics/transactionalDomains.ts → dist/heuristics/transactionalDomains.js} +8 -8
  61. package/dist/imapflow-connection.d.ts +377 -0
  62. package/dist/imapflow-connection.js +1147 -0
  63. package/dist/index.d.ts +63 -0
  64. package/dist/index.js +65 -0
  65. package/dist/list-id-backfill.d.ts +83 -0
  66. package/dist/list-id-backfill.js +132 -0
  67. package/dist/mailbox-cursor-rebuild.d.ts +74 -0
  68. package/dist/mailbox-cursor-rebuild.js +71 -0
  69. package/{src/mailbox-cursor.ts → dist/mailbox-cursor.d.ts} +20 -98
  70. package/dist/mailbox-cursor.js +102 -0
  71. package/dist/mailbox-intent.d.ts +41 -0
  72. package/{src/mailbox-intent.ts → dist/mailbox-intent.js} +38 -64
  73. package/dist/mailbox-management.d.ts +240 -0
  74. package/dist/mailbox-management.js +576 -0
  75. package/dist/mailbox-presence.d.ts +52 -0
  76. package/{src/mailbox-presence.ts → dist/mailbox-presence.js} +19 -30
  77. package/dist/mailbox-queue.d.ts +129 -0
  78. package/dist/mailbox-queue.js +216 -0
  79. package/dist/mailbox-sync.d.ts +93 -0
  80. package/dist/mailbox-sync.js +490 -0
  81. package/dist/mailbox-upstream.d.ts +38 -0
  82. package/{src/mailbox-upstream.ts → dist/mailbox-upstream.js} +37 -47
  83. package/dist/message-category.d.ts +51 -0
  84. package/{src/message-category.ts → dist/message-category.js} +28 -71
  85. package/dist/message-move.d.ts +285 -0
  86. package/dist/message-move.js +858 -0
  87. package/dist/message-parser.d.ts +14 -0
  88. package/dist/message-parser.js +17 -0
  89. package/{src/message-presence.ts → dist/message-presence.d.ts} +1 -13
  90. package/dist/message-presence.js +29 -0
  91. package/dist/message-read.d.ts +51 -0
  92. package/dist/message-read.js +31 -0
  93. package/dist/message-sync.d.ts +345 -0
  94. package/dist/message-sync.js +1358 -0
  95. package/dist/mime-walker.d.ts +102 -0
  96. package/dist/mime-walker.js +198 -0
  97. package/dist/organize-job.d.ts +20 -0
  98. package/dist/organize-job.js +37 -0
  99. package/dist/outbox-attachment-content.d.ts +37 -0
  100. package/dist/outbox-attachment-content.js +50 -0
  101. package/dist/outbox-attachment-filename.d.ts +19 -0
  102. package/dist/outbox-attachment-filename.js +64 -0
  103. package/dist/outbox-attachment.d.ts +148 -0
  104. package/dist/outbox-attachment.js +273 -0
  105. package/dist/outbox-queue.d.ts +73 -0
  106. package/dist/outbox-queue.js +280 -0
  107. package/{src/outbox-status.ts → dist/outbox-status.d.ts} +1 -6
  108. package/dist/outbox-status.js +18 -0
  109. package/dist/ownership.d.ts +24 -0
  110. package/dist/ownership.js +83 -0
  111. package/dist/placement-move-terminal.d.ts +67 -0
  112. package/dist/placement-move-terminal.js +88 -0
  113. package/dist/placement-move.d.ts +83 -0
  114. package/dist/placement-move.js +216 -0
  115. package/{src/placement-settled.ts → dist/placement-settled.d.ts} +13 -68
  116. package/dist/placement-settled.js +75 -0
  117. package/dist/quarantine.d.ts +95 -0
  118. package/dist/quarantine.js +131 -0
  119. package/dist/refusal.d.ts +31 -0
  120. package/dist/refusal.js +3 -0
  121. package/{src/restore-source-placement.ts → dist/restore-source-placement.d.ts} +29 -97
  122. package/dist/restore-source-placement.js +79 -0
  123. package/dist/search-text-backfill.d.ts +15 -0
  124. package/dist/search-text-backfill.js +33 -0
  125. package/dist/snippet.d.ts +61 -0
  126. package/dist/snippet.js +129 -0
  127. package/dist/spam-report.d.ts +86 -0
  128. package/dist/spam-report.js +241 -0
  129. package/dist/sync-warning.d.ts +21 -0
  130. package/dist/sync-warning.js +26 -0
  131. package/dist/sync-watermarks.d.ts +138 -0
  132. package/dist/sync-watermarks.js +205 -0
  133. package/dist/test-helpers/fake-imap.d.ts +151 -0
  134. package/dist/test-helpers/fake-imap.js +508 -0
  135. package/dist/test-helpers/folder-roles.d.ts +34 -0
  136. package/dist/test-helpers/folder-roles.js +47 -0
  137. package/dist/test-helpers/mime-tree-generator.d.ts +82 -0
  138. package/dist/test-helpers/mime-tree-generator.js +329 -0
  139. package/dist/test-helpers/test-store.d.ts +69 -0
  140. package/dist/test-helpers/test-store.js +193 -0
  141. package/dist/text/normalizer.d.ts +20 -0
  142. package/dist/text/normalizer.js +102 -0
  143. package/dist/thread-message-rows.d.ts +69 -0
  144. package/dist/thread-message-rows.js +40 -0
  145. package/dist/thread-root.d.ts +7 -0
  146. package/dist/thread-root.js +25 -0
  147. package/dist/types.d.ts +343 -0
  148. package/dist/types.js +19 -0
  149. package/package.json +86 -88
  150. package/src/account-credentials.ts +0 -210
  151. package/src/attribute-mapper.test.ts +0 -104
  152. package/src/attribute-mapper.ts +0 -148
  153. package/src/body-parse.test.ts +0 -55
  154. package/src/body-part-mapper.property.test.ts +0 -354
  155. package/src/body-part-mapper.ts +0 -410
  156. package/src/body-sync-queue.test.ts +0 -64
  157. package/src/body-sync-queue.ts +0 -131
  158. package/src/body-sync-terminal.test.ts +0 -148
  159. package/src/body-sync-terminal.ts +0 -174
  160. package/src/body-sync.test.ts +0 -925
  161. package/src/body-sync.ts +0 -1891
  162. package/src/calendar-parts.ts +0 -41
  163. package/src/classification-backfill.test.ts +0 -285
  164. package/src/classification-backfill.ts +0 -363
  165. package/src/classify-message.test.ts +0 -142
  166. package/src/classify-message.ts +0 -116
  167. package/src/connection-factory.ts +0 -102
  168. package/src/connection-test.ts +0 -208
  169. package/src/credentials.test.ts +0 -181
  170. package/src/external-move.test.ts +0 -262
  171. package/src/external-move.ts +0 -285
  172. package/src/filter-operations.test.ts +0 -319
  173. package/src/filter-operations.ts +0 -377
  174. package/src/filter-update.test.ts +0 -313
  175. package/src/filters/filter-config.test.ts +0 -87
  176. package/src/filters/filter-config.ts +0 -34
  177. package/src/filters/list-id.test.ts +0 -50
  178. package/src/filters/match.test.ts +0 -392
  179. package/src/filters/match.ts +0 -225
  180. package/src/filters/pipeline.test.ts +0 -199
  181. package/src/filters/pipeline.ts +0 -170
  182. package/src/flag-push.test.ts +0 -112
  183. package/src/flag-push.ts +0 -198
  184. package/src/flag-queue.test.ts +0 -197
  185. package/src/flag-queue.ts +0 -509
  186. package/src/folder-operations.test.ts +0 -273
  187. package/src/folder-operations.ts +0 -217
  188. package/src/folder-placement.ts +0 -56
  189. package/src/forwarded-message.test.ts +0 -122
  190. package/src/heuristics/authenticationResults.test.ts +0 -189
  191. package/src/heuristics/authenticationResults.ts +0 -212
  192. package/src/heuristics/classifyByHeaders.test.ts +0 -889
  193. package/src/heuristics/classifyByHeaders.ts +0 -467
  194. package/src/heuristics/classifyPlacement.test.ts +0 -366
  195. package/src/heuristics/classifyPlacement.ts +0 -211
  196. package/src/heuristics/classifyRealMail.test.ts +0 -242
  197. package/src/heuristics/machineSenders.test.ts +0 -101
  198. package/src/heuristics/senderMismatch.test.ts +0 -346
  199. package/src/heuristics/senderMismatch.ts +0 -259
  200. package/src/imapflow-connect-retry.test.ts +0 -97
  201. package/src/imapflow-connection.test.ts +0 -503
  202. package/src/imapflow-connection.ts +0 -1476
  203. package/src/index.ts +0 -397
  204. package/src/list-id-backfill.test.ts +0 -207
  205. package/src/list-id-backfill.ts +0 -274
  206. package/src/mailbox-cursor-rebuild.test.ts +0 -171
  207. package/src/mailbox-cursor-rebuild.ts +0 -146
  208. package/src/mailbox-cursor.test.ts +0 -141
  209. package/src/mailbox-delete-intent.test.ts +0 -119
  210. package/src/mailbox-management.test.ts +0 -243
  211. package/src/mailbox-management.ts +0 -838
  212. package/src/mailbox-presence.test.ts +0 -74
  213. package/src/mailbox-queue.ts +0 -397
  214. package/src/mailbox-rename-intent.test.ts +0 -220
  215. package/src/mailbox-rename-subtree.test.ts +0 -287
  216. package/src/mailbox-sync.test.ts +0 -286
  217. package/src/mailbox-sync.ts +0 -724
  218. package/src/message-move.test.ts +0 -726
  219. package/src/message-move.ts +0 -1518
  220. package/src/message-parser.ts +0 -26
  221. package/src/message-presence.test.ts +0 -50
  222. package/src/message-read.ts +0 -87
  223. package/src/message-save-retry.test.ts +0 -90
  224. package/src/message-sync-flags.test.ts +0 -167
  225. package/src/message-sync.test.ts +0 -762
  226. package/src/message-sync.ts +0 -2133
  227. package/src/mime-walker.test.ts +0 -311
  228. package/src/mime-walker.ts +0 -306
  229. package/src/organize-job.ts +0 -93
  230. package/src/outbox-attachment-content.test.ts +0 -153
  231. package/src/outbox-attachment-content.ts +0 -104
  232. package/src/outbox-attachment-filename.test.ts +0 -132
  233. package/src/outbox-attachment-filename.ts +0 -76
  234. package/src/outbox-attachment.test.ts +0 -626
  235. package/src/outbox-attachment.ts +0 -536
  236. package/src/outbox-failed-recovery.test.ts +0 -299
  237. package/src/outbox-queue.test.ts +0 -315
  238. package/src/outbox-queue.ts +0 -525
  239. package/src/ownership.ts +0 -140
  240. package/src/placement-move-terminal.ts +0 -145
  241. package/src/placement-move.ts +0 -363
  242. package/src/placement-settled.test.ts +0 -147
  243. package/src/placement.test.ts +0 -404
  244. package/src/quarantine.test.ts +0 -133
  245. package/src/quarantine.ts +0 -229
  246. package/src/refusal.ts +0 -29
  247. package/src/snippet.test.ts +0 -213
  248. package/src/snippet.ts +0 -152
  249. package/src/spam-report.test.ts +0 -329
  250. package/src/spam-report.ts +0 -357
  251. package/src/sync-warning.test.ts +0 -213
  252. package/src/sync-warning.ts +0 -56
  253. package/src/sync-watermarks.test.ts +0 -409
  254. package/src/sync-watermarks.ts +0 -319
  255. package/src/system-flag-wire-format.test.ts +0 -77
  256. package/src/test-helpers/fake-imap.ts +0 -690
  257. package/src/test-helpers/folder-roles.ts +0 -66
  258. package/src/test-helpers/mime-tree-generator.ts +0 -461
  259. package/src/test-helpers/test-queue.ts +0 -56
  260. package/src/test-helpers/test-store.ts +0 -299
  261. package/src/text/normalizer.test.ts +0 -223
  262. package/src/text/normalizer.ts +0 -140
  263. package/src/thread-message-rows.ts +0 -77
  264. package/src/thread-root.test.ts +0 -29
  265. package/src/thread-root.ts +0 -51
  266. package/src/types/mailparser-augment.d.ts +0 -23
  267. package/src/types/natural-porter-stemmer.d.ts +0 -50
  268. package/src/types/stopword.d.ts +0 -69
  269. package/src/types.ts +0 -371
  270. package/tsconfig.json +0 -8
@@ -1,925 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { afterEach, beforeEach, describe, it } from "node:test";
3
- import { DEFAULT_CALENDAR_URL_SEGMENT } from "@remit/calendar-service";
4
- import type {
5
- AccountItem,
6
- AddressFlags,
7
- MailboxItem,
8
- UpdateMessageInput,
9
- } from "@remit/data-ports";
10
- import {
11
- MessageGoneFromServerError,
12
- MessagePlacementUnsettledError,
13
- } from "@remit/data-ports/errors";
14
- import { deriveAddressId } from "@remit/data-ports/id";
15
- import {
16
- CalendarInviteMethod,
17
- CalendarSource,
18
- CalendarSuggestionState,
19
- FilterClauseField,
20
- FilterScope,
21
- MessageCategory,
22
- MessageClassificationState,
23
- } from "@remit/domain-enums";
24
- import type { StorageService } from "@remit/storage-service";
25
- import { type BodySyncFolder, BodySyncService } from "./body-sync.js";
26
- import { NO_ACTION } from "./filters/match.js";
27
- import { FlagPushService } from "./flag-push.js";
28
- import { FlagQueueService } from "./flag-queue.js";
29
- import { PlacementMoveService } from "./placement-move.js";
30
- import { QuarantineService } from "./quarantine.js";
31
- import { SyncWarningService } from "./sync-warning.js";
32
- import { eml, FakeImapServer } from "./test-helpers/fake-imap.js";
33
- import {
34
- captureQueue,
35
- messageAtUid,
36
- openStore,
37
- type QueueCapture,
38
- type Store,
39
- seedAccount,
40
- seedMailbox,
41
- silentLog,
42
- syncFolder,
43
- } from "./test-helpers/test-store.js";
44
-
45
- const LINKEDIN = eml(
46
- {
47
- From: "LinkedIn <messages-noreply@linkedin.com>",
48
- Subject: "You have a new invitation",
49
- "List-Id": "LinkedIn Invitations <invitations.linkedin.com>",
50
- "List-Unsubscribe": "<https://www.linkedin.com/e/unsub>",
51
- },
52
- "invitation body text",
53
- );
54
-
55
- const PERSONAL = eml(
56
- { From: "Alex <alex@example.com>", Subject: "Dinner Friday?" },
57
- "Are you free Friday night?",
58
- );
59
-
60
- const DEMOTE = eml({
61
- From: "Support <support@evil-mimic.example>",
62
- Subject: "Verify your account",
63
- "Authentication-Results": "mx.example.com; dmarc=fail",
64
- "DKIM-Signature": "v=1; a=rsa-sha256; d=relay.example.net; s=sel; b=xxx",
65
- "X-Spam-Status": "No, score=0.1",
66
- });
67
-
68
- const SPAM_FLAGGED_DMARC_PASS = eml({
69
- From: "Sender <someone@example.com>",
70
- "Authentication-Results": "mx.example.com; dmarc=pass",
71
- "X-Spam-Status": "Yes, score=6.0",
72
- });
73
-
74
- const FROM_SOMEONE = eml({ From: "Sender <someone@example.com>" });
75
-
76
- const TRAVEL_INVITATION = eml({
77
- From: "Someone <someone@example.com>",
78
- Subject: "You have a new invitation to Travel",
79
- });
80
-
81
- const ICAL_UID = "invite-4711@example.test";
82
-
83
- const invitation = (
84
- sequence = 0,
85
- method = "REQUEST",
86
- start = "DTSTART:20260901T080000Z",
87
- ): Buffer =>
88
- Buffer.from(
89
- [
90
- "From: Organizer <organizer@example.test>",
91
- "To: user@example.test",
92
- "Subject: Quarterly review",
93
- 'Content-Type: multipart/alternative; boundary="bnd"',
94
- "",
95
- "--bnd",
96
- "Content-Type: text/plain",
97
- "",
98
- "You are invited.",
99
- "--bnd",
100
- `Content-Type: text/calendar; method=${method}; charset="UTF-8"`,
101
- "",
102
- "BEGIN:VCALENDAR",
103
- "VERSION:2.0",
104
- "PRODID:-//Example Corp//Scheduler//EN",
105
- `METHOD:${method}`,
106
- "BEGIN:VEVENT",
107
- `UID:${ICAL_UID}`,
108
- "DTSTAMP:20260801T090000Z",
109
- `SEQUENCE:${sequence}`,
110
- start,
111
- "SUMMARY:Quarterly review",
112
- "ORGANIZER:mailto:organizer@example.test",
113
- "END:VEVENT",
114
- "END:VCALENDAR",
115
- "--bnd--",
116
- "",
117
- ].join("\r\n"),
118
- );
119
-
120
- const MISSING_BODY = "file:///nonexistent/remit/body.eml";
121
-
122
- interface World {
123
- store: Store;
124
- imap: FakeImapServer;
125
- queue: QueueCapture;
126
- account: AccountItem;
127
- inbox: MailboxItem;
128
- junk: MailboxItem;
129
- archive: MailboxItem;
130
- travel: MailboxItem;
131
- }
132
-
133
- let world: World;
134
-
135
- beforeEach(async () => {
136
- const store = await openStore();
137
- const account = await seedAccount(store);
138
- const imap = new FakeImapServer();
139
- for (const path of ["INBOX", "Junk", "Archive", "Travel"])
140
- imap.addFolder(path);
141
- world = {
142
- store,
143
- imap,
144
- queue: captureQueue(),
145
- account,
146
- inbox: await seedMailbox(store, account, "INBOX"),
147
- junk: await seedMailbox(store, account, "Junk", { specialUse: "Junk" }),
148
- archive: await seedMailbox(store, account, "Archive", {
149
- specialUse: "Archive",
150
- }),
151
- travel: await seedMailbox(store, account, "Travel"),
152
- };
153
- });
154
-
155
- afterEach(async () => {
156
- world.queue.restore();
157
- await world.store.close();
158
- });
159
-
160
- interface Wiring {
161
- storage?: StorageService;
162
- placement?: boolean;
163
- filters?: boolean;
164
- quarantine?: boolean;
165
- unsubscribe?: boolean;
166
- calendar?: boolean;
167
- }
168
-
169
- const bodySync = (wiring: Wiring = {}): BodySyncService => {
170
- const { store } = world;
171
- const placementMoveService = new PlacementMoveService({
172
- messageService: store.message,
173
- threadMessageService: store.threadMessage,
174
- markerService: store.placementMove,
175
- addressService: store.address,
176
- mailboxSpecialUseService: store.mailboxSpecialUse,
177
- mailboxService: store.mailbox,
178
- sqsQueueUrl: "https://sqs.test/message-mgmt",
179
- });
180
- const flagQueueService = new FlagQueueService({
181
- messageFlagService: store.messageFlag,
182
- messageService: store.message,
183
- threadMessageService: store.threadMessage,
184
- flagPushService: new FlagPushService({
185
- markerService: store.flagPush,
186
- sqsQueueUrl: "https://sqs.test/flags",
187
- }),
188
- });
189
- return new BodySyncService(
190
- store.message,
191
- wiring.storage ?? store.storage(),
192
- store.threadMessage,
193
- store.address,
194
- store.envelope,
195
- new SyncWarningService(store.syncWarning),
196
- silentLog,
197
- wiring.placement
198
- ? {
199
- mailboxSpecialUseService: store.mailboxSpecialUse,
200
- placementMoveService,
201
- }
202
- : undefined,
203
- wiring.filters
204
- ? {
205
- filterService: store.filter,
206
- messageLabelService: store.messageLabel,
207
- mailboxService: store.mailbox,
208
- placementMoveService,
209
- }
210
- : undefined,
211
- wiring.quarantine
212
- ? {
213
- quarantineService: new QuarantineService(
214
- store.quarantine,
215
- store.mailboxSpecialUse,
216
- "sha-test",
217
- silentLog,
218
- ),
219
- mailboxId: world.inbox.mailboxId,
220
- uidValidity: world.inbox.uidValidity,
221
- attempts: 1,
222
- }
223
- : undefined,
224
- wiring.unsubscribe ? { flagQueueService } : undefined,
225
- wiring.calendar
226
- ? {
227
- calendarSuggestionService: store.calendarSuggestion,
228
- calendarStore: store.calendarStore,
229
- filterService: store.filter,
230
- }
231
- : undefined,
232
- );
233
- };
234
-
235
- const placementOf = (mailbox: MailboxItem): BodySyncFolder => ({
236
- mailboxId: mailbox.mailboxId,
237
- fullPath: mailbox.fullPath,
238
- placement:
239
- mailbox === world.inbox
240
- ? "inbox"
241
- : mailbox === world.junk
242
- ? "junk"
243
- : "other",
244
- });
245
-
246
- const arrive = async (
247
- raw: Buffer,
248
- mailbox: MailboxItem = world.inbox,
249
- update: UpdateMessageInput = {},
250
- ): Promise<string> => {
251
- const uid = world.imap.deliver(mailbox.fullPath, raw);
252
- await syncFolder(world.store, world.account, mailbox, world.imap);
253
- const { messageId } = await messageAtUid(world.store, mailbox, uid);
254
- await world.store.message.update(messageId, update);
255
- return messageId;
256
- };
257
-
258
- const read = (
259
- service: BodySyncService,
260
- messageId: string,
261
- mailbox: MailboxItem = world.inbox,
262
- ) =>
263
- service.fetchAndGetBody(
264
- messageId,
265
- world.account.accountId,
266
- world.account.accountConfigId,
267
- placementOf(mailbox),
268
- async () => world.imap.connection(),
269
- );
270
-
271
- const sync = (
272
- service: BodySyncService,
273
- messageIds: string[],
274
- options: { force?: boolean; mailbox?: MailboxItem } = {},
275
- ) =>
276
- service.syncBodies(
277
- messageIds,
278
- world.account.accountId,
279
- world.account.accountConfigId,
280
- {
281
- ...placementOf(options.mailbox ?? world.inbox),
282
- uidValidity: (options.mailbox ?? world.inbox).uidValidity,
283
- },
284
- async () => world.imap.connection(),
285
- options.force ?? false,
286
- );
287
-
288
- const flagSender = async (email: string, flags: AddressFlags) => {
289
- const [localPart, domain] = email.split("@");
290
- const addressId = deriveAddressId(world.account.accountConfigId, email);
291
- await world.store.address.upsertAddress({
292
- addressId,
293
- accountConfigId: world.account.accountConfigId,
294
- localPart,
295
- domain,
296
- normalizedEmail: email,
297
- normalizedCompound: email,
298
- });
299
- await world.store.address.updateAddress(
300
- world.account.accountConfigId,
301
- addressId,
302
- { flags },
303
- );
304
- };
305
-
306
- const messageRow = (messageId: string) => world.store.message.get(messageId);
307
-
308
- const threadRows = (messageId: string) =>
309
- world.store.threadMessage.findAllByMessageId(
310
- world.account.accountConfigId,
311
- messageId,
312
- );
313
-
314
- const addFilter = (
315
- clauses: Array<{ field: string; value: string }>,
316
- actionMailboxId: string = NO_ACTION,
317
- actionLabelId: string = NO_ACTION,
318
- ) =>
319
- world.store.filter.create({
320
- accountConfigId: world.account.accountConfigId,
321
- name: "rule",
322
- scope: FilterScope.Standing,
323
- literalClauses: clauses as never,
324
- actionMailboxId,
325
- actionLabelId,
326
- });
327
-
328
- describe("classification is decided on the pass that first stores a body", () => {
329
- it("writes the category, the examined mark and every denormalized field", async () => {
330
- const messageId = await arrive(LINKEDIN);
331
- await world.store.threadMessage.create({
332
- ...(await threadRows(messageId))[0],
333
- threadId: "drifted-root",
334
- });
335
-
336
- await sync(bodySync(), [messageId]);
337
-
338
- const row = await messageRow(messageId);
339
- assert.equal(row.category, MessageCategory.social);
340
- assert.equal(row.classificationState, MessageClassificationState.Examined);
341
- assert.ok(row.bodyStorageKey);
342
- const rows = await threadRows(messageId);
343
- assert.equal(rows.length, 2);
344
- for (const thread of rows) {
345
- assert.equal(thread.category, MessageCategory.social);
346
- assert.equal(thread.listId, "invitations.linkedin.com");
347
- assert.equal(thread.snippet, "invitation body text");
348
- }
349
- });
350
-
351
- it("examines a body the classifier has nothing to say about once", async () => {
352
- const messageId = await arrive(PERSONAL);
353
- const service = bodySync();
354
-
355
- await sync(service, [messageId]);
356
- const second = await sync(service, [messageId]);
357
-
358
- assert.equal(second.skippedCount, 1);
359
- const row = await messageRow(messageId);
360
- assert.equal(row.classificationState, MessageClassificationState.Examined);
361
- });
362
-
363
- it("leaves mail stored before the classifier ran for the backfill to find", async () => {
364
- const messageId = await arrive(LINKEDIN, world.inbox, {
365
- bodyStorageKey: MISSING_BODY,
366
- classificationState: MessageClassificationState.NotExamined,
367
- });
368
-
369
- const result = await sync(bodySync(), [messageId]);
370
-
371
- assert.equal(result.skippedCount, 1);
372
- const row = await messageRow(messageId);
373
- assert.equal(
374
- row.classificationState,
375
- MessageClassificationState.NotExamined,
376
- );
377
- assert.equal(row.category, MessageCategory.uncategorized);
378
- });
379
-
380
- it("classifies by the sender's category override instead of the headers", async () => {
381
- await flagSender("messages-noreply@linkedin.com", {
382
- category: { value: MessageCategory.personal, setAt: 1 },
383
- });
384
- const messageId = await arrive(LINKEDIN);
385
-
386
- await read(bodySync(), messageId);
387
-
388
- assert.equal(
389
- (await messageRow(messageId)).category,
390
- MessageCategory.personal,
391
- );
392
- assert.equal(
393
- (await threadRows(messageId))[0].category,
394
- MessageCategory.personal,
395
- );
396
- });
397
-
398
- it("does not reach back to examined mail when the sender is reclassified later", async () => {
399
- const messageId = await arrive(LINKEDIN, world.inbox, {
400
- bodyStorageKey: MISSING_BODY,
401
- classificationState: MessageClassificationState.Examined,
402
- });
403
- await flagSender("messages-noreply@linkedin.com", {
404
- category: { value: MessageCategory.transactional, setAt: 1 },
405
- });
406
-
407
- await sync(bodySync(), [messageId]);
408
-
409
- assert.equal(
410
- (await messageRow(messageId)).category,
411
- MessageCategory.uncategorized,
412
- );
413
- });
414
- });
415
-
416
- describe("a re-store re-fetches bytes and re-decides nothing", () => {
417
- const decidedRow = (
418
- overrides: UpdateMessageInput = {},
419
- ): UpdateMessageInput => ({
420
- bodyStorageKey: MISSING_BODY,
421
- category: MessageCategory.marketing,
422
- classificationState: MessageClassificationState.Examined,
423
- placementDecidedAt: 500,
424
- ...overrides,
425
- });
426
-
427
- for (const path of ["the missing-object read", "a forced sync"] as const) {
428
- const restore = (
429
- service: BodySyncService,
430
- messageId: string,
431
- mailbox: MailboxItem = world.inbox,
432
- ) =>
433
- path === "a forced sync"
434
- ? sync(service, [messageId], { force: true, mailbox })
435
- : read(service, messageId, mailbox);
436
-
437
- it(`keeps category, placement and read state through ${path}`, async () => {
438
- await flagSender("support@evil-mimic.example", {
439
- category: { value: MessageCategory.personal, setAt: 1 },
440
- unsubscribed: { value: true, setAt: 1 },
441
- });
442
- const messageId = await arrive(DEMOTE, world.inbox, decidedRow());
443
-
444
- await restore(
445
- bodySync({ placement: true, filters: true, unsubscribe: true }),
446
- messageId,
447
- );
448
-
449
- const row = await messageRow(messageId);
450
- assert.notEqual(row.bodyStorageKey, MISSING_BODY);
451
- assert.equal(row.category, MessageCategory.marketing);
452
- assert.equal(row.mailboxId, world.inbox.mailboxId);
453
- assert.equal(row.movedByRemit, false);
454
- assert.equal(row.placementDecidedAt, 500);
455
- assert.equal((await threadRows(messageId))[0].isRead, false);
456
- assert.deepEqual(world.queue.sent, []);
457
- });
458
-
459
- it(`does not re-fire a standing filter through ${path}`, async () => {
460
- await addFilter(
461
- [{ field: FilterClauseField.Subject, value: "invitation" }],
462
- world.junk.mailboxId,
463
- );
464
- const messageId = await arrive(
465
- TRAVEL_INVITATION,
466
- world.travel,
467
- decidedRow(),
468
- );
469
-
470
- await restore(bodySync({ filters: true }), messageId, world.travel);
471
-
472
- const row = await messageRow(messageId);
473
- assert.equal(row.mailboxId, world.travel.mailboxId);
474
- assert.equal(row.filterMove, undefined);
475
- });
476
- }
477
-
478
- it("first-stores the never-stored message of a forced batch with the full pass", async () => {
479
- await addFilter(
480
- [{ field: FilterClauseField.Subject, value: "invitation" }],
481
- world.travel.mailboxId,
482
- );
483
- const stored = await arrive(DEMOTE, world.inbox, decidedRow());
484
- const fresh = await arrive(TRAVEL_INVITATION);
485
-
486
- const result = await sync(
487
- bodySync({ placement: true, filters: true }),
488
- [stored, fresh],
489
- { force: true },
490
- );
491
-
492
- assert.deepEqual(result.syncedMessageIds.sort(), [fresh, stored].sort());
493
- assert.equal((await messageRow(stored)).mailboxId, world.inbox.mailboxId);
494
- const filed = await messageRow(fresh);
495
- assert.equal(filed.mailboxId, world.travel.mailboxId);
496
- assert.equal(filed.movedByRemit, true);
497
- });
498
- });
499
-
500
- describe("placement follows the sender's flags", () => {
501
- it("files a blocked sender's inbox mail to Junk with no authentication signal", async () => {
502
- await flagSender("someone@example.com", {
503
- blocked: { value: true, setAt: 1_000 },
504
- });
505
- const messageId = await arrive(FROM_SOMEONE);
506
-
507
- await read(bodySync({ placement: true }), messageId);
508
-
509
- const row = await messageRow(messageId);
510
- assert.equal(row.mailboxId, world.junk.mailboxId);
511
- assert.equal(row.movedByRemit, true);
512
- assert.ok(row.placementDecidedAt);
513
- assert.equal(
514
- (await world.store.placementMove.find(messageId))?.state,
515
- "queued",
516
- );
517
- assert.deepEqual(
518
- world.queue.sent.map((event) => event.body.type),
519
- ["PLACEMENT_MOVE_PUSH"],
520
- );
521
- });
522
-
523
- it("leaves an unflagged sender's inbox mail where it is", async () => {
524
- const messageId = await arrive(FROM_SOMEONE);
525
-
526
- await read(bodySync({ placement: true }), messageId);
527
-
528
- assert.equal(
529
- (await messageRow(messageId)).mailboxId,
530
- world.inbox.mailboxId,
531
- );
532
- assert.equal(await world.store.placementMove.find(messageId), null);
533
- });
534
-
535
- it("rescues from Junk when vip was set after blocked", async () => {
536
- await flagSender("someone@example.com", {
537
- blocked: { value: true, setAt: 1_000 },
538
- vip: { value: true, setAt: 5_000 },
539
- });
540
- const messageId = await arrive(SPAM_FLAGGED_DMARC_PASS, world.junk);
541
-
542
- await read(bodySync({ placement: true }), messageId, world.junk);
543
-
544
- assert.equal(
545
- (await messageRow(messageId)).mailboxId,
546
- world.inbox.mailboxId,
547
- );
548
- });
549
-
550
- it("files an autoArchive sender's inbox mail to Archive", async () => {
551
- await flagSender("someone@example.com", {
552
- autoArchive: { value: true, setAt: 1_000 },
553
- });
554
- const messageId = await arrive(FROM_SOMEONE);
555
-
556
- await read(bodySync({ placement: true }), messageId);
557
-
558
- assert.equal(
559
- (await messageRow(messageId)).mailboxId,
560
- world.archive.mailboxId,
561
- );
562
- });
563
-
564
- it("lets a blocked demote win over autoArchive", async () => {
565
- await flagSender("someone@example.com", {
566
- blocked: { value: true, setAt: 1_000 },
567
- autoArchive: { value: true, setAt: 1_000 },
568
- });
569
- const messageId = await arrive(FROM_SOMEONE);
570
-
571
- await read(bodySync({ placement: true }), messageId);
572
-
573
- assert.equal((await messageRow(messageId)).mailboxId, world.junk.mailboxId);
574
- });
575
-
576
- it("does not auto-archive mail out of Junk", async () => {
577
- await flagSender("someone@example.com", {
578
- autoArchive: { value: true, setAt: 1_000 },
579
- });
580
- const messageId = await arrive(SPAM_FLAGGED_DMARC_PASS, world.junk);
581
-
582
- await read(bodySync({ placement: true }), messageId, world.junk);
583
-
584
- assert.equal((await messageRow(messageId)).mailboxId, world.junk.mailboxId);
585
- });
586
-
587
- it("does not re-file mail Remit already placed", async () => {
588
- await flagSender("someone@example.com", {
589
- autoArchive: { value: true, setAt: 1_000 },
590
- });
591
- const messageId = await arrive(FROM_SOMEONE, world.inbox, {
592
- movedByRemit: true,
593
- });
594
-
595
- await read(bodySync({ placement: true }), messageId);
596
-
597
- assert.equal(
598
- (await messageRow(messageId)).mailboxId,
599
- world.inbox.mailboxId,
600
- );
601
- });
602
- });
603
-
604
- describe("a standing filter runs on the first store", () => {
605
- it("moves the message and records where it came from", async () => {
606
- const filter = await addFilter(
607
- [{ field: FilterClauseField.Subject, value: "invitation" }],
608
- world.travel.mailboxId,
609
- );
610
- const messageId = await arrive(TRAVEL_INVITATION);
611
-
612
- await read(bodySync({ filters: true }), messageId);
613
-
614
- const row = await messageRow(messageId);
615
- assert.equal(row.mailboxId, world.travel.mailboxId);
616
- assert.equal(row.movedByRemit, true);
617
- assert.deepEqual(
618
- {
619
- filterId: row.filterMove?.filterId,
620
- sourceMailboxId: row.filterMove?.sourceMailboxId,
621
- destinationMailboxId: row.filterMove?.destinationMailboxId,
622
- },
623
- {
624
- filterId: filter.filterId,
625
- sourceMailboxId: world.inbox.mailboxId,
626
- destinationMailboxId: world.travel.mailboxId,
627
- },
628
- );
629
- });
630
-
631
- it("records no move for a message already in the filter's destination", async () => {
632
- await addFilter(
633
- [{ field: FilterClauseField.Subject, value: "invitation" }],
634
- world.travel.mailboxId,
635
- );
636
- const messageId = await arrive(TRAVEL_INVITATION, world.travel);
637
-
638
- await read(bodySync({ filters: true }), messageId, world.travel);
639
-
640
- const row = await messageRow(messageId);
641
- assert.equal(row.filterMove, undefined);
642
- assert.equal(row.movedByRemit, false);
643
- });
644
- });
645
-
646
- describe("an unsubscribed sender's mail is marked read once", () => {
647
- it("marks it read on the first store", async () => {
648
- await flagSender("someone@example.com", {
649
- unsubscribed: { value: true, setAt: 1 },
650
- });
651
- const messageId = await arrive(FROM_SOMEONE);
652
-
653
- await read(bodySync({ unsubscribe: true }), messageId);
654
-
655
- assert.equal((await threadRows(messageId))[0].isRead, true);
656
- assert.ok(
657
- await world.store.flagPush.find(messageId, "\\Seen"),
658
- "the read owes IMAP a \\Seen push",
659
- );
660
- });
661
-
662
- it("leaves read state alone once the flag is unset", async () => {
663
- await flagSender("someone@example.com", {
664
- unsubscribed: { value: false, setAt: 1 },
665
- });
666
- const messageId = await arrive(FROM_SOMEONE);
667
-
668
- await read(bodySync({ unsubscribe: true }), messageId);
669
-
670
- assert.equal((await threadRows(messageId))[0].isRead, false);
671
- });
672
- });
673
-
674
- describe("reading a message the mail server no longer holds", () => {
675
- const gone = async (inFlight = false) => {
676
- const messageId = await arrive(FROM_SOMEONE);
677
- const { uid } = await messageRow(messageId);
678
- if (inFlight) {
679
- await world.store.message.transitionPlacement(
680
- messageId,
681
- { status: "active" },
682
- { status: "moving", syncStatus: "pending" },
683
- );
684
- }
685
- world.imap.expunge("INBOX", uid);
686
- const outcome = await read(bodySync(), messageId).then(
687
- () => assert.fail("expected the read to be refused"),
688
- (error: unknown) => error,
689
- );
690
- return { messageId, outcome };
691
- };
692
-
693
- it("answers 404 and removes the settled row with its thread entries", async () => {
694
- const { messageId, outcome } = await gone();
695
-
696
- assert.ok(outcome instanceof MessageGoneFromServerError);
697
- assert.equal(outcome.statusCode, 404);
698
- await assert.rejects(messageRow(messageId));
699
- assert.deepEqual(await threadRows(messageId), []);
700
- });
701
-
702
- it("answers 409 and deletes nothing while our own move is in flight", async () => {
703
- const { messageId, outcome } = await gone(true);
704
-
705
- assert.ok(outcome instanceof MessagePlacementUnsettledError);
706
- assert.equal(outcome.statusCode, 409);
707
- assert.equal((await threadRows(messageId)).length, 1);
708
- });
709
-
710
- it("answers 409 when another writer moved the row while the server was asked", async () => {
711
- const messageId = await arrive(FROM_SOMEONE);
712
- const { uid } = await messageRow(messageId);
713
- world.imap.expunge("INBOX", uid);
714
- world.imap.onFetchBody = async () => {
715
- await world.store.message.transitionPlacement(
716
- messageId,
717
- { status: "active" },
718
- { mailboxId: world.travel.mailboxId, uid: 99 },
719
- );
720
- };
721
-
722
- await assert.rejects(
723
- read(bodySync(), messageId),
724
- MessagePlacementUnsettledError,
725
- );
726
- assert.equal((await threadRows(messageId)).length, 1);
727
- });
728
-
729
- it("fails loudly and deletes nothing when the server still lists the UID", async () => {
730
- const messageId = await arrive(FROM_SOMEONE);
731
- const connection = world.imap.connection();
732
- connection.fetchMessageBody = async () => null;
733
-
734
- await assert.rejects(
735
- bodySync().fetchAndGetBody(
736
- messageId,
737
- world.account.accountId,
738
- world.account.accountConfigId,
739
- placementOf(world.inbox),
740
- async () => connection,
741
- ),
742
- /which the server still lists/,
743
- );
744
- assert.ok(await messageRow(messageId));
745
- });
746
- });
747
-
748
- describe("body sync and the quarantine", () => {
749
- it("skips a uid already set aside without fetching it", async () => {
750
- const messageId = await arrive(FROM_SOMEONE);
751
- const { uid } = await messageRow(messageId);
752
- await new QuarantineService(
753
- world.store.quarantine,
754
- world.store.mailboxSpecialUse,
755
- "sha-test",
756
- silentLog,
757
- ).record(
758
- {
759
- accountId: world.account.accountId,
760
- accountConfigId: world.account.accountConfigId,
761
- mailboxId: world.inbox.mailboxId,
762
- mailboxPath: "INBOX",
763
- uidValidity: world.inbox.uidValidity,
764
- attempts: 3,
765
- },
766
- uid,
767
- { stage: "BodyParse", code: "UnreadableBody", message: "boundary" },
768
- );
769
-
770
- const result = await bodySync({ quarantine: true }).syncBodies(
771
- [messageId],
772
- world.account.accountId,
773
- world.account.accountConfigId,
774
- { ...placementOf(world.inbox), uidValidity: world.inbox.uidValidity },
775
- async () => assert.fail("a quarantined message is not fetched"),
776
- );
777
-
778
- assert.equal(result.skippedCount, 1);
779
- assert.equal((await messageRow(messageId)).bodyStorageKey, undefined);
780
- });
781
-
782
- it("requeues a storage failure rather than calling the message unreadable", async () => {
783
- const messageId = await arrive(FROM_SOMEONE);
784
-
785
- await assert.rejects(
786
- sync(
787
- bodySync({ quarantine: true, storage: world.store.brokenStorage() }),
788
- [messageId],
789
- ),
790
- );
791
-
792
- assert.deepEqual(
793
- await world.store.quarantine.listByAccountConfigId(
794
- world.account.accountConfigId,
795
- ),
796
- [],
797
- );
798
- assert.equal((await messageRow(messageId)).bodyStorageKey, undefined);
799
- });
800
- });
801
-
802
- describe("mail-derived calendar suggestions", () => {
803
- const suggestions = (messageId: string) =>
804
- world.store.calendarSuggestion.listByMessage(
805
- world.account.accountConfigId,
806
- messageId,
807
- );
808
-
809
- it("offers an invitation as one Pending card carrying its bytes", async () => {
810
- const messageId = await arrive(invitation());
811
-
812
- await read(bodySync({ calendar: true }), messageId);
813
-
814
- const [row, ...rest] = await suggestions(messageId);
815
- assert.deepEqual(rest, []);
816
- assert.equal(row.state, CalendarSuggestionState.Pending);
817
- assert.equal(row.icalUid, ICAL_UID);
818
- assert.equal(row.method, CalendarInviteMethod.Request);
819
- assert.equal(row.organizer, "organizer@example.test");
820
- assert.match(row.icalData, /UID:invite-4711@example\.test/);
821
- });
822
-
823
- it("reads a floating start in the collection's own zone", async () => {
824
- await world.store.calendarCollection.create({
825
- accountConfigId: world.account.accountConfigId,
826
- urlSegment: DEFAULT_CALENDAR_URL_SEGMENT,
827
- displayName: "Calendar",
828
- source: CalendarSource.Default,
829
- timezone: "Europe/Amsterdam",
830
- });
831
- const messageId = await arrive(
832
- invitation(0, "REQUEST", "DTSTART:20260901T100000"),
833
- );
834
-
835
- await read(bodySync({ calendar: true }), messageId);
836
-
837
- const [row] = await suggestions(messageId);
838
- assert.equal(row.dtStart, "2026-09-01T10:00:00+02:00");
839
- assert.equal(row.zoneCertainty, "Local");
840
- });
841
-
842
- it("offers no card for a sender the user muted", async () => {
843
- await addFilter([
844
- { field: FilterClauseField.From, value: "organizer@example.test" },
845
- ]);
846
- const messageId = await arrive(invitation());
847
-
848
- await read(bodySync({ calendar: true }), messageId);
849
-
850
- assert.deepEqual(await suggestions(messageId), []);
851
- });
852
-
853
- it("still offers a card when the rule about this sender files its mail", async () => {
854
- await addFilter(
855
- [{ field: FilterClauseField.From, value: "organizer@example.test" }],
856
- world.travel.mailboxId,
857
- );
858
- const messageId = await arrive(invitation());
859
-
860
- await read(bodySync({ calendar: true }), messageId);
861
-
862
- assert.equal((await suggestions(messageId)).length, 1);
863
- });
864
-
865
- it("offers nothing on a re-store", async () => {
866
- const messageId = await arrive(invitation(), world.inbox, {
867
- bodyStorageKey: MISSING_BODY,
868
- });
869
-
870
- await read(bodySync({ calendar: true }), messageId);
871
-
872
- assert.deepEqual(await suggestions(messageId), []);
873
- });
874
-
875
- it("supersedes the earlier revision when a higher SEQUENCE arrives", async () => {
876
- const service = bodySync({ calendar: true });
877
- const first = await arrive(invitation(0));
878
- const second = await arrive(invitation(1));
879
-
880
- await read(service, first);
881
- await read(service, second);
882
-
883
- assert.equal(
884
- (await suggestions(first))[0].state,
885
- CalendarSuggestionState.Superseded,
886
- );
887
- assert.equal(
888
- (await suggestions(second))[0].state,
889
- CalendarSuggestionState.Pending,
890
- );
891
- });
892
-
893
- it("offers a cancellation as its own card", async () => {
894
- const messageId = await arrive(invitation(1, "CANCEL"));
895
-
896
- await read(bodySync({ calendar: true }), messageId);
897
-
898
- const [row] = await suggestions(messageId);
899
- assert.equal(row.method, CalendarInviteMethod.Cancel);
900
- assert.equal(row.acceptedCalendarObjectId, "");
901
- });
902
-
903
- it("stores the body when the iCalendar is unreadable", async () => {
904
- const messageId = await arrive(
905
- Buffer.from(
906
- [
907
- "From: Organizer <organizer@example.test>",
908
- 'Content-Type: multipart/alternative; boundary="bnd"',
909
- "",
910
- "--bnd",
911
- "Content-Type: text/calendar",
912
- "",
913
- "BEGIN:VCALENDAR",
914
- "--bnd--",
915
- "",
916
- ].join("\r\n"),
917
- ),
918
- );
919
-
920
- await read(bodySync({ calendar: true }), messageId);
921
-
922
- assert.ok((await messageRow(messageId)).bodyStorageKey);
923
- assert.deepEqual(await suggestions(messageId), []);
924
- });
925
- });