@remit/mailbox-service 0.0.111 → 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 +156 -235
  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 +5 -4
  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/adversarial-mime.e2e.test.ts +0 -307
  74. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  75. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  76. package/src/body-sync-category-denormalize.test.ts +0 -352
  77. package/src/body-sync-category-override.test.ts +0 -501
  78. package/src/body-sync-category-writeonce.test.ts +0 -234
  79. package/src/body-sync-filter.test.ts +0 -237
  80. package/src/body-sync-message-gone.test.ts +0 -139
  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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.111",
3
+ "version": "0.0.112",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -32,25 +32,40 @@
32
32
  "./types": {
33
33
  "types": "./src/types.ts",
34
34
  "default": "./src/types.ts"
35
+ },
36
+ "./test-helpers/fake-imap": {
37
+ "types": "./src/test-helpers/fake-imap.ts",
38
+ "default": "./src/test-helpers/fake-imap.ts"
39
+ },
40
+ "./test-helpers/test-queue": {
41
+ "types": "./src/test-helpers/test-queue.ts",
42
+ "default": "./src/test-helpers/test-queue.ts"
43
+ },
44
+ "./test-helpers/test-store": {
45
+ "types": "./src/test-helpers/test-store.ts",
46
+ "default": "./src/test-helpers/test-store.ts"
35
47
  }
36
48
  },
37
49
  "scripts": {
38
50
  "test:typecheck": "tsgo --noEmit",
39
51
  "test:run": "node $NODE_TEST_FLAGS --env-file=../../localhost-test-unit.env --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=86 --test 'src/**/*.test.ts'",
40
- "test:integ": "RUN_INTEG_TESTS=1 node --env-file=../../localhost-test-unit.env --import tsx --test 'src/**/*.integ.test.ts'",
41
- "test:e2e": "RUN_E2E_TESTS=1 node --env-file=../../localhost-test-e2e.env --import tsx --test --test-concurrency=1 'src/**/*.e2e.test.ts'",
42
52
  "test": "npm run test:typecheck && npm run test:run"
43
53
  },
54
+ "devDependencies": {
55
+ "@remit/drizzle-service": "*",
56
+ "@types/better-sqlite3": "^7.6.13",
57
+ "aws-sdk-client-mock": "*",
58
+ "better-sqlite3": "^12.11.1"
59
+ },
44
60
  "dependencies": {
45
61
  "imapflow": "^1.3.3",
46
- "mailparser": "^3.9.14",
47
- "nodemailer": "^9.0.3",
62
+ "mailparser": "3.12.2",
63
+ "nodemailer": "^10.0.6",
48
64
  "p-map": "^7.0.4",
49
65
  "tldts": "^7.4.9",
50
66
  "@remit/data-ports": "*",
51
67
  "@remit/domain-enums": "*",
52
68
  "@remit/mail-oauth-service": "*",
53
- "@remit/search-service": "*",
54
69
  "@remit/secrets-service": "*",
55
70
  "@remit/sqs-client": "*",
56
71
  "@remit/storage-service": "*",
@@ -46,7 +46,12 @@
46
46
 
47
47
  import { Buffer } from "node:buffer";
48
48
  import type { BodyPartItem } from "@remit/data-ports";
49
- import type { Attachment, ParsedMail } from "mailparser";
49
+ import {
50
+ encapsulatedParts,
51
+ outermostMessages,
52
+ ownParts,
53
+ } from "@remit/data-ports/message-parts";
54
+ import { type Attachment, type ParsedMail, simpleParser } from "mailparser";
50
55
 
51
56
  export interface BodyPartContentPair {
52
57
  partPath: string;
@@ -358,3 +363,48 @@ export const mapBodyPartsToContent = (
358
363
 
359
364
  return pairs;
360
365
  };
366
+
367
+ /**
368
+ * The parse every part's bytes are paired against. A message/rfc822 part stays
369
+ * one leaf holding the raw forwarded message whatever its disposition, so the
370
+ * forwarded message's own parts come only from parsing it in turn.
371
+ */
372
+ export const parsePartSource = (source: Buffer): Promise<ParsedMail> =>
373
+ simpleParser(source, { ignoreEmbedded: true });
374
+
375
+ /**
376
+ * `mapBodyPartsToContent`, extended to forwarded messages. mailparser hands a
377
+ * message/rfc822 part over as one attachment holding the raw forwarded
378
+ * message, so the parts inside it have no bytes in `parsed`. Each forwarded
379
+ * message is parsed on its own and its parts are paired against that parse,
380
+ * with their paths rebased so `<messagePath>.0` is the forwarded message's
381
+ * root — the shape `walkMimeStructure` stores.
382
+ */
383
+ export const mapMessagePartsToContent = async (
384
+ bodyParts: readonly MapperInput[],
385
+ parsed: ParsedMail,
386
+ context: MapperContext = {},
387
+ ): Promise<BodyPartContentPair[]> => {
388
+ const messages = outermostMessages(bodyParts);
389
+ const pairs = mapBodyPartsToContent(ownParts(bodyParts), parsed, context);
390
+
391
+ for (const message of messages) {
392
+ const raw = pairs.find((pair) => pair.partPath === message.partPath);
393
+ const inner = encapsulatedParts(bodyParts, message.partPath);
394
+ if (!raw || inner.length === 0) continue;
395
+ const prefix = `${message.partPath}.`;
396
+ const nested = await mapMessagePartsToContent(
397
+ inner.map((part) => ({
398
+ ...part,
399
+ partPath: part.partPath.slice(prefix.length),
400
+ })),
401
+ await parsePartSource(raw.content),
402
+ context,
403
+ );
404
+ for (const pair of nested) {
405
+ pairs.push({ ...pair, partPath: `${prefix}${pair.partPath}` });
406
+ }
407
+ }
408
+
409
+ return pairs;
410
+ };
@@ -1,123 +1,63 @@
1
1
  import assert from "node:assert";
2
- import { describe, it, mock } from "node:test";
3
- import {
4
- type BodySyncQueueLogger,
5
- BodySyncQueueService,
6
- } from "./body-sync-queue.js";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import { BodySyncQueueService } from "./body-sync-queue.js";
4
+ import { captureQueue, type QueueCapture } from "./test-helpers/test-store.js";
7
5
 
8
- interface CapturedInput {
9
- QueueUrl: string;
10
- MessageBody: string;
11
- MessageGroupId?: string;
12
- MessageDeduplicationId?: string;
13
- }
6
+ let capture: QueueCapture;
7
+ let errors: Array<[Record<string, unknown>, string]>;
14
8
 
15
- const buildService = (
16
- overrides: {
17
- sqsQueueUrl?: string;
18
- send?: (command: { input: CapturedInput }) => Promise<unknown>;
19
- logger?: BodySyncQueueLogger;
20
- } = {},
21
- ) => {
22
- const sent: CapturedInput[] = [];
23
- const send = mock.fn(
24
- overrides.send ??
25
- (async (command: { input: CapturedInput }) => {
26
- sent.push(command.input);
27
- return { MessageId: "id" };
28
- }),
29
- );
30
- const service = new BodySyncQueueService({
31
- sqsQueueUrl:
32
- overrides.sqsQueueUrl ?? "http://localhost:9324/000/remit-body",
33
- logger: overrides.logger,
9
+ beforeEach(() => {
10
+ capture = captureQueue();
11
+ errors = [];
12
+ });
13
+
14
+ afterEach(() => capture.restore());
15
+
16
+ const request = (uid?: number) =>
17
+ new BodySyncQueueService({
18
+ sqsQueueUrl: "http://localhost:9324/000/remit-body",
19
+ logger: {
20
+ info: () => {},
21
+ error: (obj, msg) => {
22
+ errors.push([obj, msg]);
23
+ },
24
+ },
25
+ }).requestBodySync({
26
+ accountId: "acc-1",
27
+ mailboxId: "mbx-1",
28
+ messageId: "msg-1",
29
+ ...(uid === undefined ? {} : { uid }),
34
30
  });
35
- // @ts-expect-error - inject mock SQS client for the test
36
- service.sqs = { send };
37
- return { service, send, sent };
38
- };
39
31
 
40
32
  describe("BodySyncQueueService.requestBodySync", () => {
41
- it("emits a SYNC_MESSAGE_BODY event with the message id and uid pair", async () => {
42
- const { service, send, sent } = buildService();
33
+ it("asks for a forced re-sync of the message and its uid on a standard queue", async () => {
34
+ await request(42);
43
35
 
44
- await service.requestBodySync({
45
- accountId: "acc-1",
46
- mailboxId: "mbx-1",
47
- messageId: "msg-1",
48
- uid: 42,
49
- });
50
-
51
- assert.equal(send.mock.calls.length, 1);
52
- const event = JSON.parse(sent[0].MessageBody);
36
+ const [input] = capture.inputs();
37
+ const event = JSON.parse(input.MessageBody ?? "{}");
53
38
  assert.equal(event.type, "SYNC_MESSAGE_BODY");
54
- assert.equal(event.accountId, "acc-1");
55
- assert.equal(event.mailboxId, "mbx-1");
56
39
  assert.deepEqual(event.messageIds, ["msg-1"]);
57
40
  assert.deepEqual(event.messages, [{ messageId: "msg-1", uid: 42 }]);
58
- // The worker's skip guard must be bypassed for this message — a read
59
- // miss already proved bodyStorageKey is stale.
60
41
  assert.equal(event.force, true);
61
- // A standard queue must not carry FIFO parameters.
62
- assert.equal(sent[0].MessageGroupId, undefined);
42
+ assert.equal(input.MessageGroupId, undefined);
63
43
  });
64
44
 
65
45
  it("omits the uid pair when no uid is known", async () => {
66
- const { service, sent } = buildService();
46
+ await request();
67
47
 
68
- await service.requestBodySync({
69
- accountId: "acc-1",
70
- mailboxId: "mbx-1",
71
- messageId: "msg-1",
72
- });
73
-
74
- const event = JSON.parse(sent[0].MessageBody);
75
- assert.deepEqual(event.messageIds, ["msg-1"]);
76
- assert.equal(event.messages, undefined);
48
+ assert.equal(capture.sent[0].body.messages, undefined);
77
49
  });
78
50
 
79
- it("never rejects when the queue send fails — the read path already returned 202", async () => {
80
- const { service } = buildService({
81
- send: async () => {
82
- throw new Error("queue down");
83
- },
84
- });
85
-
86
- await assert.doesNotReject(() =>
87
- service.requestBodySync({
88
- accountId: "acc-1",
89
- mailboxId: "mbx-1",
90
- messageId: "msg-1",
91
- }),
92
- );
93
- });
94
-
95
- it("logs an alertable body_sync_cue_enqueue_failed error instead of rejecting — the swallow is not silent operationally, only silent to the caller (RFC 033 D5)", async () => {
96
- const errorCalls: Array<[Record<string, unknown>, string]> = [];
97
- const logger: BodySyncQueueLogger = {
98
- info: () => {},
99
- error: (obj, msg) => {
100
- errorCalls.push([obj, msg]);
101
- },
102
- };
103
- const { service } = buildService({
104
- send: async () => {
105
- throw new Error("queue down");
106
- },
107
- logger,
51
+ it("never rejects when the queue is down, and raises an alertable error", async () => {
52
+ capture.answer(async () => {
53
+ throw new Error("queue down");
108
54
  });
109
55
 
110
- await service.requestBodySync({
111
- accountId: "acc-1",
112
- mailboxId: "mbx-1",
113
- messageId: "msg-1",
114
- });
56
+ await assert.doesNotReject(request());
115
57
 
116
- assert.equal(errorCalls.length, 1);
117
- const [fields, message] = errorCalls[0];
58
+ assert.equal(errors.length, 1);
59
+ const [fields, message] = errors[0];
118
60
  assert.equal(fields.alert, "body_sync_cue_enqueue_failed");
119
- assert.equal(fields.accountId, "acc-1");
120
- assert.equal(fields.mailboxId, "mbx-1");
121
61
  assert.equal(fields.messageId, "msg-1");
122
62
  assert.match(message, /read path returned a retryable 202/);
123
63
  });