@remit/mailbox-service 0.0.1

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 (124) hide show
  1. package/README.md +147 -0
  2. package/package.json +65 -0
  3. package/src/account-credentials.ts +128 -0
  4. package/src/adversarial-mime.e2e.test.ts +311 -0
  5. package/src/attribute-mapper.test.ts +78 -0
  6. package/src/attribute-mapper.ts +147 -0
  7. package/src/body-part-mapper.property.test.ts +354 -0
  8. package/src/body-part-mapper.ts +360 -0
  9. package/src/body-sync-queue.test.ts +124 -0
  10. package/src/body-sync-queue.ts +131 -0
  11. package/src/body-sync-terminal.test.ts +265 -0
  12. package/src/body-sync-terminal.ts +172 -0
  13. package/src/body-sync.ts +1202 -0
  14. package/src/connection-factory.ts +102 -0
  15. package/src/connection-test.ts +208 -0
  16. package/src/credentials.test.ts +181 -0
  17. package/src/filters/match.test.ts +181 -0
  18. package/src/filters/match.ts +143 -0
  19. package/src/filters/pipeline.ts +243 -0
  20. package/src/flag-push-terminal.test.ts +156 -0
  21. package/src/flag-push-terminal.ts +106 -0
  22. package/src/flag-push.test.ts +408 -0
  23. package/src/flag-push.ts +198 -0
  24. package/src/flag-queue.ts +500 -0
  25. package/src/heuristics/classifyByHeaders.test.ts +578 -0
  26. package/src/heuristics/classifyByHeaders.ts +369 -0
  27. package/src/heuristics/classifyPlacement.ts +96 -0
  28. package/src/heuristics/socialDomains.ts +23 -0
  29. package/src/heuristics/transactionalDomains.ts +26 -0
  30. package/src/imapflow-connect-retry.test.ts +108 -0
  31. package/src/imapflow-connection.e2e.test.ts +355 -0
  32. package/src/imapflow-connection.integ.test.ts +327 -0
  33. package/src/imapflow-connection.test.ts +161 -0
  34. package/src/imapflow-connection.ts +1374 -0
  35. package/src/index.ts +214 -0
  36. package/src/mailbox-cursor-rebuild.test.ts +171 -0
  37. package/src/mailbox-cursor-rebuild.ts +146 -0
  38. package/src/mailbox-cursor.test.ts +249 -0
  39. package/src/mailbox-cursor.ts +177 -0
  40. package/src/mailbox-management.test.ts +103 -0
  41. package/src/mailbox-management.ts +235 -0
  42. package/src/mailbox-queue.ts +258 -0
  43. package/src/mailbox-sync.test.ts +153 -0
  44. package/src/mailbox-sync.ts +590 -0
  45. package/src/message-move.ts +825 -0
  46. package/src/message-parser.ts +26 -0
  47. package/src/message-sync.ts +1021 -0
  48. package/src/mime-walker.test.ts +311 -0
  49. package/src/mime-walker.ts +305 -0
  50. package/src/outbox-queue.ts +294 -0
  51. package/src/pass-through-unit-of-work.ts +17 -0
  52. package/src/placement-move-terminal.test.ts +179 -0
  53. package/src/placement-move-terminal.ts +104 -0
  54. package/src/placement-move.ts +224 -0
  55. package/src/snippet.test.ts +213 -0
  56. package/src/snippet.ts +152 -0
  57. package/src/stale-message-reconcile.test.ts +82 -0
  58. package/src/stale-message-reconcile.ts +55 -0
  59. package/src/test-helpers/isolated-mailbox.ts +44 -0
  60. package/src/test-helpers/mailfuzz-connection.ts +27 -0
  61. package/src/test-helpers/mime-tree-generator.ts +461 -0
  62. package/src/text/normalizer.test.ts +223 -0
  63. package/src/text/normalizer.ts +140 -0
  64. package/src/types/mailparser-augment.d.ts +15 -0
  65. package/src/types/natural-porter-stemmer.d.ts +50 -0
  66. package/src/types/stopword.d.ts +69 -0
  67. package/src/types.ts +349 -0
  68. package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
  69. package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
  70. package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
  71. package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
  72. package/test/fixtures/mime/02-flat-text-html.eml +10 -0
  73. package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
  74. package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
  75. package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
  76. package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
  77. package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
  78. package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
  79. package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
  80. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
  81. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
  82. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
  83. package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
  84. package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
  85. package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
  86. package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
  87. package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
  88. package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
  89. package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
  90. package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
  91. package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
  92. package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
  93. package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
  94. package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
  95. package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
  96. package/test/fixtures/mime/10-calendar-invite.eml +33 -0
  97. package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
  98. package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
  99. package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
  100. package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
  101. package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
  102. package/test/fixtures/mime/12-base64-binary.eml +20 -0
  103. package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
  104. package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
  105. package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
  106. package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
  107. package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
  108. package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
  109. package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
  110. package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
  111. package/test/fixtures/mime/15-empty-text-part.eml +18 -0
  112. package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
  113. package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
  114. package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
  115. package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
  124. package/tsconfig.json +8 -0
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # @remit/mailbox-service
2
+
3
+ IMAP mailbox synchronization service for Remit. Provides connection management, mailbox discovery, and message sync with DynamoDB persistence.
4
+
5
+ ## Features
6
+
7
+ - **ImapFlow-based**: Modern async/await IMAP client with native envelope parsing
8
+ - **Mailbox Sync**: Discovers and syncs mailbox metadata from IMAP to DynamoDB
9
+ - **Message Sync**: Newest-first sync strategy with dual-watermark tracking
10
+ - **Address Extraction**: Parses and stores envelope addresses with role tracking
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ npm install
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ### IMAP Connection
21
+
22
+ ```typescript
23
+ import {
24
+ ImapFlowConnection,
25
+ createImapFlowConnectionFromAccount,
26
+ } from "@remit/mailbox-service";
27
+
28
+ // From account data
29
+ const connection = createImapFlowConnectionFromAccount(
30
+ {
31
+ imapHost: "imap.example.com",
32
+ imapPort: 993,
33
+ imapTls: true,
34
+ username: "user",
35
+ },
36
+ "password",
37
+ );
38
+
39
+ // Or direct configuration
40
+ const connection = new ImapFlowConnection({
41
+ host: "imap.example.com",
42
+ port: 993,
43
+ tls: true,
44
+ user: "user",
45
+ password: "password",
46
+ });
47
+
48
+ await connection.connect();
49
+ const mailboxes = await connection.listMailboxes();
50
+ await connection.disconnect();
51
+ ```
52
+
53
+ ### Mailbox Sync
54
+
55
+ ```typescript
56
+ import { MailboxSyncService } from "@remit/mailbox-service";
57
+
58
+ const syncService = new MailboxSyncService({
59
+ client: dynamoDBClient,
60
+ table: "remit-table",
61
+ });
62
+
63
+ const result = await syncService.syncMailboxes(
64
+ { accountId: "acc-123" },
65
+ connection,
66
+ );
67
+ // result: { created: 5, updated: 2, deleted: 0 }
68
+ ```
69
+
70
+ ### Message Sync
71
+
72
+ ```typescript
73
+ import { MessageSyncService } from "@remit/mailbox-service";
74
+
75
+ const messageSyncService = new MessageSyncService(
76
+ () => createConnection(), // connection factory
77
+ mailboxService,
78
+ messageService,
79
+ envelopeService,
80
+ addressService,
81
+ logger,
82
+ );
83
+
84
+ const synced = await messageSyncService.syncMessages(
85
+ mailboxId,
86
+ accountConfigId,
87
+ 50, // batch size
88
+ );
89
+ ```
90
+
91
+ ## Exports
92
+
93
+ ### Connections
94
+
95
+ | Export | Description |
96
+ | ------------------------------------- | ------------------------------------ |
97
+ | `ImapFlowConnection` | ImapFlow-based IMAP connection class |
98
+ | `createImapFlowConnectionFromAccount` | Factory from account data |
99
+ | `createConnection` | Connection factory |
100
+ | `createConnectionFromAccount` | Account-based connection factory |
101
+
102
+ ### Services
103
+
104
+ | Export | Description |
105
+ | -------------------- | ----------------------------------------- |
106
+ | `MailboxSyncService` | Syncs mailbox list from IMAP to DynamoDB |
107
+ | `MessageSyncService` | Syncs messages with newest-first strategy |
108
+
109
+ ### Types
110
+
111
+ | Export | Description |
112
+ | ---------------------- | ---------------------------- |
113
+ | `IImapConnection` | Connection interface |
114
+ | `ImapConnectionConfig` | Connection configuration |
115
+ | `ImapConnectionState` | Connection state enum |
116
+ | `ImapBoxStatus` | Mailbox status after opening |
117
+ | `FlatMailboxInfo` | Flattened mailbox info |
118
+ | `MailboxSyncResult` | Sync operation result |
119
+ | `ImapNamespaces` | IMAP namespace info |
120
+
121
+ ### Utilities
122
+
123
+ | Export | Description |
124
+ | --------------------- | ---------------------------------- |
125
+ | `parseImapAttributes` | Parse IMAP mailbox attributes |
126
+ | `hasChildren` | Check if mailbox has children |
127
+ | `isNoSelect` | Check if mailbox is non-selectable |
128
+
129
+ ## Sync Strategy
130
+
131
+ Message sync uses a dual-watermark approach:
132
+
133
+ ```
134
+ highWaterMarkUid: 150 ─────────────────────────────────────┐
135
+ │ New messages
136
+ lastSyncUid: 100 ─────────────────────────────────────┤ (synced first)
137
+ │ │
138
+ │ Already synced │
139
+ │ │
140
+ lastSyncUid progress ─────────────────────────────────────┤
141
+ │ Backfill │
142
+ UID 1 ─────────────────────────────────────┘
143
+ ```
144
+
145
+ 1. **New messages** (UID > highWaterMarkUid) are synced first
146
+ 2. **Backfill** (UID < lastSyncUid) continues in subsequent batches
147
+ 3. Both watermarks update after each batch for resumability
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@remit/mailbox-service",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./src/index.ts",
10
+ "default": "./src/index.ts"
11
+ },
12
+ "./body-sync-queue": {
13
+ "types": "./src/body-sync-queue.ts",
14
+ "default": "./src/body-sync-queue.ts"
15
+ },
16
+ "./account-credentials": {
17
+ "types": "./src/account-credentials.ts",
18
+ "default": "./src/account-credentials.ts"
19
+ }
20
+ },
21
+ "scripts": {
22
+ "test:typecheck": "tsgo --noEmit",
23
+ "test:run": "node --env-file=../../localhost-test-unit.env --import tsx --test 'src/**/*.test.ts'",
24
+ "test:integ": "RUN_INTEG_TESTS=1 node --env-file=../../localhost-test-unit.env --import tsx --test 'src/**/*.integ.test.ts'",
25
+ "test:e2e": "RUN_E2E_TESTS=1 node --env-file=../../localhost-test-e2e.env --import tsx --test --test-concurrency=1 'src/**/*.e2e.test.ts'",
26
+ "test": "npm run test:typecheck && npm run test:run"
27
+ },
28
+ "devDependencies": {
29
+ "@aws-sdk/client-dynamodb": "*",
30
+ "@aws-sdk/client-sqs": "*",
31
+ "@aws-sdk/lib-dynamodb": "*",
32
+ "@remit/data-ports": "*",
33
+ "@remit/electrodb-entities": "*",
34
+ "@remit/domain-enums": "*",
35
+ "@remit/mail-oauth-service": "*",
36
+ "@remit/secrets-service": "*",
37
+ "@remit/sqs-client": "*",
38
+ "@remit/storage-service": "*",
39
+ "@types/mailparser": "*",
40
+ "@types/nodemailer": "*",
41
+ "@types/stopword": "^2.0.3",
42
+ "@types/uuid": "^10.0.0",
43
+ "electrodb": "*",
44
+ "expect-env": "*",
45
+ "franc": "*",
46
+ "natural": "^8.1.1",
47
+ "short-uuid": "*",
48
+ "stopword": "*"
49
+ },
50
+ "dependencies": {
51
+ "imapflow": "^1.3.3",
52
+ "mailparser": "^3.9.14",
53
+ "nodemailer": "^9.0.3",
54
+ "p-map": "^7.0.4"
55
+ },
56
+ "license": "MIT",
57
+ "publishConfig": {
58
+ "access": "public"
59
+ },
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "git+https://github.com/remit-mail/remit.git",
63
+ "directory": "packages/mailbox-service"
64
+ }
65
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * account-credentials.ts
3
+ *
4
+ * Single place in the codebase that branches on account.authType.
5
+ * All sync handlers (IMAP, SMTP) must call resolveConnectionCredentials
6
+ * rather than duplicating credential logic.
7
+ *
8
+ * Grep-verifiable constraint: `account.authType` must only appear here.
9
+ */
10
+
11
+ import type { AccountItem } from "@remit/data-ports";
12
+ import { AccountAuthType, ConnectionState } from "@remit/domain-enums";
13
+ import type { MailOAuthService } from "@remit/mail-oauth-service";
14
+ import { RefreshTokenError } from "@remit/mail-oauth-service";
15
+ import {
16
+ deserializeEncryptedPayload,
17
+ type SecretsService,
18
+ serializeEncryptedPayload,
19
+ } from "@remit/secrets-service";
20
+ import type { MailCredentials } from "./types.js";
21
+
22
+ export interface AccountCredentialsDeps {
23
+ secrets: Pick<SecretsService, "decrypt" | "encrypt">;
24
+ tokenService: Pick<MailOAuthService, "getAccessToken">;
25
+ /**
26
+ * Called when the OAuth provider rotated the refresh token.
27
+ * Must be awaited BEFORE the credentials are returned so the new token
28
+ * is persisted before any IMAP command is issued.
29
+ */
30
+ persistRotatedToken: (
31
+ accountId: string,
32
+ encryptedHash: string,
33
+ updatedAt: number,
34
+ ) => Promise<void>;
35
+ }
36
+
37
+ /**
38
+ * Resolve IMAP credentials for an account.
39
+ *
40
+ * - Password accounts: decrypt passwordHash → return password credential.
41
+ * - OAuth accounts: decrypt oauthRefreshTokenHash → mint access token via
42
+ * tokenService. If the provider rotated the refresh token, persist the
43
+ * new token via `deps.persistRotatedToken` BEFORE returning credentials.
44
+ *
45
+ * Throws `RefreshTokenError` on OAuth failures; callers should handle:
46
+ * - `kind === "reauth-required"` → set connectionState to reauth_required, ACK
47
+ * - `kind === "transient"` → rethrow (SQS retry)
48
+ * - `kind === "config"` → rethrow (SQS retry / alert)
49
+ */
50
+ export const resolveConnectionCredentials = async (
51
+ account: AccountItem,
52
+ deps: AccountCredentialsDeps,
53
+ ): Promise<MailCredentials> => {
54
+ const authType = account.authType ?? AccountAuthType.Password;
55
+
56
+ if (authType === AccountAuthType.OauthMicrosoft) {
57
+ return resolveOauthCredentials(account, deps);
58
+ }
59
+
60
+ // Default: password auth
61
+ return resolvePasswordCredentials(account, deps.secrets);
62
+ };
63
+
64
+ const resolvePasswordCredentials = async (
65
+ account: AccountItem,
66
+ secrets: Pick<SecretsService, "decrypt">,
67
+ ): Promise<MailCredentials> => {
68
+ if (!account.passwordHash) {
69
+ throw new Error(
70
+ `Account ${account.accountId} has authType=password but no passwordHash`,
71
+ );
72
+ }
73
+ const password = await secrets.decrypt(
74
+ deserializeEncryptedPayload(JSON.parse(account.passwordHash)),
75
+ );
76
+ return { kind: "password", password };
77
+ };
78
+
79
+ const resolveOauthCredentials = async (
80
+ account: AccountItem,
81
+ deps: AccountCredentialsDeps,
82
+ ): Promise<MailCredentials> => {
83
+ if (!account.oauthRefreshTokenHash) {
84
+ throw new Error(
85
+ `Account ${account.accountId} has authType=oauthMicrosoft but no oauthRefreshTokenHash`,
86
+ );
87
+ }
88
+
89
+ const refreshToken = await deps.secrets.decrypt(
90
+ deserializeEncryptedPayload(JSON.parse(account.oauthRefreshTokenHash)),
91
+ );
92
+
93
+ // getAccessToken throws RefreshTokenError on failure
94
+ const tokenSet = await deps.tokenService.getAccessToken(
95
+ account.accountId,
96
+ refreshToken,
97
+ );
98
+
99
+ // If the provider rotated the refresh token, persist it BEFORE returning.
100
+ // This guarantees the new token is stored even if a subsequent error occurs.
101
+ if (tokenSet.refreshToken) {
102
+ const encryptedPayload = await deps.secrets.encrypt(tokenSet.refreshToken);
103
+ const encryptedHash = JSON.stringify(
104
+ serializeEncryptedPayload(encryptedPayload),
105
+ );
106
+ await deps.persistRotatedToken(
107
+ account.accountId,
108
+ encryptedHash,
109
+ Date.now(),
110
+ );
111
+ }
112
+
113
+ return { kind: "accessToken", accessToken: tokenSet.accessToken };
114
+ };
115
+
116
+ /**
117
+ * Build the encrypted hash string for an OAuth refresh token.
118
+ * Helper used when first storing an OAuth token (e.g. after OAuth callback).
119
+ */
120
+ export const encryptRefreshToken = async (
121
+ refreshToken: string,
122
+ secrets: Pick<SecretsService, "encrypt">,
123
+ ): Promise<string> => {
124
+ const payload = await secrets.encrypt(refreshToken);
125
+ return JSON.stringify(serializeEncryptedPayload(payload));
126
+ };
127
+
128
+ export { RefreshTokenError, ConnectionState };
@@ -0,0 +1,311 @@
1
+ /**
2
+ * E2E adversarial MIME corpus (#402).
3
+ *
4
+ * Each .eml fixture under `test/fixtures/mime-adversarial-e2e/` is APPENDed to
5
+ * the mailfuzz Dovecot INBOX, fetched back via IMAP, then parsed end-to-end
6
+ * with mailparser. The suite asserts the two behaviours called out in #402:
7
+ *
8
+ * 1. body renders — `parsed.text` or `parsed.html` is non-empty
9
+ * 2. attachments are listable — when the fixture declares attachments, every
10
+ * expected filename appears in `parsed.attachments`
11
+ * with non-zero content length
12
+ *
13
+ * The shapes here are content-driven adversarial cases that real-world senders
14
+ * actually emit (PDF as application/octet-stream, TNEF, nested multipart/related,
15
+ * forwarded message/rfc822, calendar invites, quoted-printable soft breaks,
16
+ * 8-bit UTF-8 bodies, empty text/plain alternatives) — the same class of shape
17
+ * that caused the body-sync regression in #394.
18
+ *
19
+ * Mapper unit-level defence lives in `test/fixtures/mime/`; this suite is the
20
+ * integration-layer belt-and-braces against the same regression class.
21
+ */
22
+
23
+ import assert from "node:assert/strict";
24
+ import { readdirSync, readFileSync } from "node:fs";
25
+ import { dirname, join, resolve } from "node:path";
26
+ import { after, before, describe, test } from "node:test";
27
+ import { fileURLToPath } from "node:url";
28
+ import { type ParsedMail, simpleParser } from "mailparser";
29
+ import { uniqueMailboxName } from "./test-helpers/isolated-mailbox.js";
30
+ import {
31
+ createMailfuzzConnection,
32
+ withMailfuzzConnection,
33
+ } from "./test-helpers/mailfuzz-connection.js";
34
+
35
+ const __filename = fileURLToPath(import.meta.url);
36
+ const __dirname = dirname(__filename);
37
+ const FIXTURES_DIR = resolve(
38
+ __dirname,
39
+ "../test/fixtures/mime-adversarial-e2e",
40
+ );
41
+
42
+ interface AttachmentExpectation {
43
+ filename: string;
44
+ contentType?: string;
45
+ }
46
+
47
+ interface FixtureExpectation {
48
+ /** Subject must round-trip — proves the message we fetched is the one we appended. */
49
+ subjectIncludes: string;
50
+ /** At least one of `text` or `html` must be non-empty after parsing. */
51
+ bodyRenders: true;
52
+ /** Attachments expected after parsing. Empty array means "no attachments". */
53
+ attachments: AttachmentExpectation[];
54
+ /** Optional substring that must appear in parsed.text or parsed.html. */
55
+ bodyContains?: string;
56
+ /**
57
+ * Skip the BODYSTRUCTURE re-fetch for this fixture.
58
+ * Set when the fixture shape is synthetic and doesn't produce a meaningful
59
+ * BODYSTRUCTURE response (e.g. bare TNEF blob — see fixture 05 and #408).
60
+ */
61
+ skipBodyStructure?: true;
62
+ }
63
+
64
+ /**
65
+ * Expectations indexed by .eml basename. Each fixture must have one entry.
66
+ *
67
+ * Filename is the contract — match it exactly to the file on disk.
68
+ */
69
+ const EXPECTATIONS: Record<string, FixtureExpectation> = {
70
+ "01-octet-stream-pdf.eml": {
71
+ subjectIncludes: "Adversarial fixture 01",
72
+ bodyRenders: true,
73
+ attachments: [{ filename: "contract.pdf" }],
74
+ bodyContains: "contract PDF",
75
+ },
76
+ "02-nested-related-cid-image.eml": {
77
+ subjectIncludes: "Adversarial fixture 02",
78
+ bodyRenders: true,
79
+ attachments: [
80
+ { filename: "logo.png", contentType: "image/png" },
81
+ { filename: "invoice.pdf" },
82
+ ],
83
+ bodyContains: "Inline logo",
84
+ },
85
+ "03-calendar-invite.eml": {
86
+ subjectIncludes: "Adversarial fixture 03",
87
+ bodyRenders: true,
88
+ attachments: [{ filename: "invite.ics" }],
89
+ bodyContains: "Meeting invite",
90
+ },
91
+ "04-forwarded-rfc822-with-attachments.eml": {
92
+ subjectIncludes: "Adversarial fixture 04",
93
+ bodyRenders: true,
94
+ // mailparser surfaces the forwarded rfc822 as an attachment whose name
95
+ // derives from the Content-Disposition filename. The inner pdf is not
96
+ // surfaced as a top-level attachment because it lives inside the nested
97
+ // rfc822 — listing the rfc822 itself is the integration-layer contract.
98
+ attachments: [{ filename: "forwarded.eml" }],
99
+ bodyContains: "Forwarding",
100
+ },
101
+ "05-tnef-winmail.eml": {
102
+ subjectIncludes: "Adversarial fixture 05",
103
+ bodyRenders: true,
104
+ attachments: [{ filename: "winmail.dat" }],
105
+ bodyContains: "TNEF",
106
+ // skipBodyStructure: The TNEF fixture uses a synthetic magic-byte stub
107
+ // rather than a real-world winmail.dat capture, so the BODYSTRUCTURE
108
+ // returned by Dovecot for this fixture is not meaningful. The re-fetch
109
+ // is skipped here to avoid asserting on a shape that will change once
110
+ // the fixture is replaced with a real capture. See #408.
111
+ skipBodyStructure: true,
112
+ },
113
+ "06-8bit-non-ascii.eml": {
114
+ subjectIncludes: "Café façade",
115
+ bodyRenders: true,
116
+ attachments: [],
117
+ bodyContains: "café",
118
+ },
119
+ "07-quoted-printable-soft-breaks.eml": {
120
+ subjectIncludes: "Adversarial fixture 07",
121
+ bodyRenders: true,
122
+ attachments: [],
123
+ // "single" is split across a soft line break in the encoded source;
124
+ // only an actually-decoded quoted-printable reassembles it.
125
+ bodyContains: "single logical line",
126
+ },
127
+ "08-alternative-empty-text.eml": {
128
+ subjectIncludes: "Adversarial fixture 08",
129
+ bodyRenders: true,
130
+ attachments: [],
131
+ // text/plain is empty; rendering must fall back to html.
132
+ bodyContains: "intentionally empty",
133
+ },
134
+ };
135
+
136
+ const listEmlFixtures = (): string[] =>
137
+ readdirSync(FIXTURES_DIR)
138
+ .filter((name) => name.endsWith(".eml"))
139
+ .sort();
140
+
141
+ const loadFixtureRaw = (name: string): string => {
142
+ const path = join(FIXTURES_DIR, name);
143
+ // Read as utf8 — fixture 06 includes raw 8-bit UTF-8 bytes which IMAP
144
+ // servers accept on APPEND as long as they're valid UTF-8 octets.
145
+ return readFileSync(path, "utf8");
146
+ };
147
+
148
+ const bodyText = (parsed: ParsedMail): string => {
149
+ const text = parsed.text ?? "";
150
+ const html = typeof parsed.html === "string" ? parsed.html : "";
151
+ return `${text}\n${html}`;
152
+ };
153
+
154
+ describe(
155
+ "Adversarial MIME corpus (Dovecot) — #402",
156
+ { skip: !process.env.RUN_E2E_TESTS },
157
+ () => {
158
+ // Own mailbox instead of the shared INBOX so appending fixtures never
159
+ // collides with another e2e file's count/flag assertions (#508).
160
+ const inbox = uniqueMailboxName("E2E_adversarial");
161
+
162
+ before(async () => {
163
+ await withMailfuzzConnection(async (connection) => {
164
+ await connection.createMailbox(inbox);
165
+ });
166
+ });
167
+
168
+ after(async () => {
169
+ const connection = createMailfuzzConnection();
170
+ await connection.connect();
171
+ await connection.deleteMailbox(inbox).catch(() => {});
172
+ if (connection.isConnected) {
173
+ await connection.disconnect();
174
+ }
175
+ });
176
+
177
+ // Sanity: every .eml on disk has an expectation entry, and every
178
+ // expectation entry points at an existing file. Catches a fixture being
179
+ // added without an assertion (or vice versa).
180
+ test("every fixture has an expectation entry", () => {
181
+ const onDisk = new Set(listEmlFixtures());
182
+ const expected = new Set(Object.keys(EXPECTATIONS));
183
+ for (const name of onDisk) {
184
+ assert.ok(
185
+ expected.has(name),
186
+ `Fixture ${name} has no expectation entry in EXPECTATIONS`,
187
+ );
188
+ }
189
+ for (const name of expected) {
190
+ assert.ok(
191
+ onDisk.has(name),
192
+ `Expectation ${name} has no .eml file on disk`,
193
+ );
194
+ }
195
+ // Pin the count: #402 calls for 8 adversarial shapes.
196
+ assert.equal(
197
+ onDisk.size,
198
+ 8,
199
+ "Expected exactly 8 adversarial MIME fixtures",
200
+ );
201
+ });
202
+
203
+ for (const name of Object.keys(EXPECTATIONS).sort()) {
204
+ const expectation = EXPECTATIONS[name];
205
+ if (!expectation) continue;
206
+
207
+ test(`fixture ${name} round-trips through IMAP and parses`, async () => {
208
+ const raw = loadFixtureRaw(name);
209
+
210
+ await withMailfuzzConnection(async (connection) => {
211
+ // APPEND the fixture into this file's own mailbox. Mailfuzz uses
212
+ // Dovecot which returns a proper UID on APPEND (mokapi does not —
213
+ // see outbox-roundtrip.e2e.test.ts and the RFC 025 notes).
214
+ const appendResult = await connection.append(inbox, raw);
215
+ assert.ok(
216
+ appendResult.uid > 0,
217
+ `APPEND should return a UID for ${name}`,
218
+ );
219
+
220
+ await connection.openBox(inbox, true);
221
+
222
+ // Fetch the message we just appended back. fetchMessageBody
223
+ // returns the raw RFC822 source; mailparser handles the rest
224
+ // (multipart walking, base64/qp decoding, charset folding).
225
+ const rfc822 = await connection.fetchMessageBody(appendResult.uid);
226
+ assert.ok(
227
+ rfc822.length > 0,
228
+ `fetchMessageBody should return bytes for ${name}`,
229
+ );
230
+
231
+ // BODYSTRUCTURE re-fetch: exercises the IMAP mapper path at the
232
+ // integration boundary (the exact regression class from #394).
233
+ // Skipped for fixtures with synthetic shapes that don't produce
234
+ // meaningful BODYSTRUCTURE (see skipBodyStructure flag + #408).
235
+ // The guard in fetchMessages skips rows with undefined uid/internalDate
236
+ // so back-to-back FETCH is safe on Dovecot (see imapflow-connection.ts).
237
+ if (!expectation.skipBodyStructure) {
238
+ const refetched = await connection.fetchMessages([
239
+ appendResult.uid,
240
+ ]);
241
+ assert.ok(
242
+ refetched.length > 0,
243
+ `BODYSTRUCTURE re-fetch should return a message for ${name}`,
244
+ );
245
+ assert.ok(
246
+ refetched[0]?.bodyStructure != null,
247
+ `BODYSTRUCTURE should be present for ${name}`,
248
+ );
249
+ }
250
+
251
+ const parsed = await simpleParser(rfc822);
252
+
253
+ // Subject round-trips (proves we fetched the right message and
254
+ // any encoded-word headers decoded correctly).
255
+ assert.ok(
256
+ parsed.subject,
257
+ `Parsed subject should be present for ${name}`,
258
+ );
259
+ assert.ok(
260
+ parsed.subject?.includes(expectation.subjectIncludes),
261
+ `Subject "${parsed.subject}" should include "${expectation.subjectIncludes}" for ${name}`,
262
+ );
263
+
264
+ // Body renders — at least one of text/html is non-empty. This
265
+ // is the headline "body renders" assertion from #402.
266
+ const text = parsed.text ?? "";
267
+ const html = typeof parsed.html === "string" ? parsed.html : "";
268
+ assert.ok(
269
+ text.trim().length > 0 || html.trim().length > 0,
270
+ `Body should render (text or html non-empty) for ${name}`,
271
+ );
272
+
273
+ if (expectation.bodyContains) {
274
+ const haystack = bodyText(parsed).toLowerCase();
275
+ const needle = expectation.bodyContains.toLowerCase();
276
+ assert.ok(
277
+ haystack.includes(needle),
278
+ `Body should contain "${expectation.bodyContains}" for ${name}`,
279
+ );
280
+ }
281
+
282
+ // Attachments are listable — every expected attachment appears
283
+ // in parsed.attachments with a matching filename and non-zero
284
+ // content length. This is the "attachments listable" half of
285
+ // the #402 contract.
286
+ const attachments = parsed.attachments ?? [];
287
+ for (const expectedAtt of expectation.attachments) {
288
+ const found = attachments.find(
289
+ (a) => a.filename === expectedAtt.filename,
290
+ );
291
+ assert.ok(
292
+ found,
293
+ `Attachment "${expectedAtt.filename}" should be listable for ${name}; got [${attachments.map((a) => a.filename).join(", ")}]`,
294
+ );
295
+ assert.ok(
296
+ (found.size ?? 0) > 0,
297
+ `Attachment "${expectedAtt.filename}" should have non-zero size for ${name}`,
298
+ );
299
+ if (expectedAtt.contentType) {
300
+ assert.equal(
301
+ found.contentType,
302
+ expectedAtt.contentType,
303
+ `Attachment "${expectedAtt.filename}" should have contentType "${expectedAtt.contentType}" for ${name}`,
304
+ );
305
+ }
306
+ }
307
+ });
308
+ });
309
+ }
310
+ },
311
+ );