@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.
- package/README.md +147 -0
- package/package.json +65 -0
- package/src/account-credentials.ts +128 -0
- package/src/adversarial-mime.e2e.test.ts +311 -0
- package/src/attribute-mapper.test.ts +78 -0
- package/src/attribute-mapper.ts +147 -0
- package/src/body-part-mapper.property.test.ts +354 -0
- package/src/body-part-mapper.ts +360 -0
- package/src/body-sync-queue.test.ts +124 -0
- package/src/body-sync-queue.ts +131 -0
- package/src/body-sync-terminal.test.ts +265 -0
- package/src/body-sync-terminal.ts +172 -0
- package/src/body-sync.ts +1202 -0
- package/src/connection-factory.ts +102 -0
- package/src/connection-test.ts +208 -0
- package/src/credentials.test.ts +181 -0
- package/src/filters/match.test.ts +181 -0
- package/src/filters/match.ts +143 -0
- package/src/filters/pipeline.ts +243 -0
- package/src/flag-push-terminal.test.ts +156 -0
- package/src/flag-push-terminal.ts +106 -0
- package/src/flag-push.test.ts +408 -0
- package/src/flag-push.ts +198 -0
- package/src/flag-queue.ts +500 -0
- package/src/heuristics/classifyByHeaders.test.ts +578 -0
- package/src/heuristics/classifyByHeaders.ts +369 -0
- package/src/heuristics/classifyPlacement.ts +96 -0
- package/src/heuristics/socialDomains.ts +23 -0
- package/src/heuristics/transactionalDomains.ts +26 -0
- package/src/imapflow-connect-retry.test.ts +108 -0
- package/src/imapflow-connection.e2e.test.ts +355 -0
- package/src/imapflow-connection.integ.test.ts +327 -0
- package/src/imapflow-connection.test.ts +161 -0
- package/src/imapflow-connection.ts +1374 -0
- package/src/index.ts +214 -0
- package/src/mailbox-cursor-rebuild.test.ts +171 -0
- package/src/mailbox-cursor-rebuild.ts +146 -0
- package/src/mailbox-cursor.test.ts +249 -0
- package/src/mailbox-cursor.ts +177 -0
- package/src/mailbox-management.test.ts +103 -0
- package/src/mailbox-management.ts +235 -0
- package/src/mailbox-queue.ts +258 -0
- package/src/mailbox-sync.test.ts +153 -0
- package/src/mailbox-sync.ts +590 -0
- package/src/message-move.ts +825 -0
- package/src/message-parser.ts +26 -0
- package/src/message-sync.ts +1021 -0
- package/src/mime-walker.test.ts +311 -0
- package/src/mime-walker.ts +305 -0
- package/src/outbox-queue.ts +294 -0
- package/src/pass-through-unit-of-work.ts +17 -0
- package/src/placement-move-terminal.test.ts +179 -0
- package/src/placement-move-terminal.ts +104 -0
- package/src/placement-move.ts +224 -0
- package/src/snippet.test.ts +213 -0
- package/src/snippet.ts +152 -0
- package/src/stale-message-reconcile.test.ts +82 -0
- package/src/stale-message-reconcile.ts +55 -0
- package/src/test-helpers/isolated-mailbox.ts +44 -0
- package/src/test-helpers/mailfuzz-connection.ts +27 -0
- package/src/test-helpers/mime-tree-generator.ts +461 -0
- package/src/text/normalizer.test.ts +223 -0
- package/src/text/normalizer.ts +140 -0
- package/src/types/mailparser-augment.d.ts +15 -0
- package/src/types/natural-porter-stemmer.d.ts +50 -0
- package/src/types/stopword.d.ts +69 -0
- package/src/types.ts +349 -0
- package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
- package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
- package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
- package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
- package/test/fixtures/mime/02-flat-text-html.eml +10 -0
- package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
- package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
- package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
- package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
- package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
- package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
- package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
- package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
- package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
- package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
- package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
- package/test/fixtures/mime/10-calendar-invite.eml +33 -0
- package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
- package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
- package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
- package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
- package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
- package/test/fixtures/mime/12-base64-binary.eml +20 -0
- package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
- package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
- package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
- package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
- package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
- package/test/fixtures/mime/15-empty-text-part.eml +18 -0
- package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
- package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
- package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
- package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
- 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
|
+
);
|