@remit/mailbox-service 0.0.111 → 0.0.113
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/dist/account-credentials.d.ts +83 -0
- package/dist/account-credentials.js +104 -0
- package/dist/attribute-mapper.d.ts +36 -0
- package/dist/attribute-mapper.js +119 -0
- package/dist/body-parse.d.ts +28 -0
- package/{src/body-parse.ts → dist/body-parse.js} +18 -26
- package/dist/body-part-mapper.d.ts +92 -0
- package/dist/body-part-mapper.js +301 -0
- package/dist/body-sync-queue.d.ts +59 -0
- package/dist/body-sync-queue.js +56 -0
- package/dist/body-sync-terminal.d.ts +58 -0
- package/dist/body-sync-terminal.js +95 -0
- package/dist/body-sync.d.ts +351 -0
- package/dist/body-sync.js +1204 -0
- package/dist/calendar-parts.d.ts +23 -0
- package/dist/calendar-parts.js +18 -0
- package/dist/classification-backfill.d.ts +91 -0
- package/dist/classification-backfill.js +180 -0
- package/dist/classify-message.d.ts +31 -0
- package/dist/classify-message.js +89 -0
- package/dist/connection-factory.d.ts +54 -0
- package/dist/connection-factory.js +54 -0
- package/dist/connection-test.d.ts +41 -0
- package/dist/connection-test.js +152 -0
- package/dist/external-move.d.ts +94 -0
- package/dist/external-move.js +173 -0
- package/dist/filter-operations.d.ts +42 -0
- package/dist/filter-operations.js +211 -0
- package/dist/filters/filter-config.d.ts +14 -0
- package/dist/filters/filter-config.js +10 -0
- package/{src/filters/list-id.ts → dist/filters/list-id.d.ts} +2 -16
- package/dist/filters/list-id.js +26 -0
- package/dist/filters/match.d.ts +69 -0
- package/dist/filters/match.js +165 -0
- package/dist/filters/pipeline.d.ts +64 -0
- package/dist/filters/pipeline.js +100 -0
- package/{src/flag-push-terminal.ts → dist/flag-push-terminal.d.ts} +19 -77
- package/dist/flag-push-terminal.js +63 -0
- package/dist/flag-push.d.ts +69 -0
- package/dist/flag-push.js +111 -0
- package/dist/flag-queue.d.ts +175 -0
- package/dist/flag-queue.js +291 -0
- package/dist/folder-operations.d.ts +39 -0
- package/dist/folder-operations.js +112 -0
- package/dist/folder-placement.d.ts +13 -0
- package/dist/folder-placement.js +23 -0
- package/dist/heuristics/authenticationResults.d.ts +10 -0
- package/dist/heuristics/authenticationResults.js +187 -0
- package/dist/heuristics/classifyByHeaders.d.ts +82 -0
- package/dist/heuristics/classifyByHeaders.js +365 -0
- package/dist/heuristics/classifyPlacement.d.ts +67 -0
- package/dist/heuristics/classifyPlacement.js +153 -0
- package/dist/heuristics/machineSenders.d.ts +21 -0
- package/{src/heuristics/machineSenders.ts → dist/heuristics/machineSenders.js} +34 -40
- package/dist/heuristics/senderMismatch.d.ts +81 -0
- package/dist/heuristics/senderMismatch.js +192 -0
- package/dist/heuristics/socialDomains.d.ts +14 -0
- package/{src/heuristics/socialDomains.ts → dist/heuristics/socialDomains.js} +9 -9
- package/dist/heuristics/transactionalDomains.d.ts +18 -0
- package/{src/heuristics/transactionalDomains.ts → dist/heuristics/transactionalDomains.js} +8 -8
- package/dist/imapflow-connection.d.ts +377 -0
- package/dist/imapflow-connection.js +1147 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +65 -0
- package/dist/list-id-backfill.d.ts +83 -0
- package/dist/list-id-backfill.js +132 -0
- package/dist/mailbox-cursor-rebuild.d.ts +74 -0
- package/dist/mailbox-cursor-rebuild.js +71 -0
- package/{src/mailbox-cursor.ts → dist/mailbox-cursor.d.ts} +20 -98
- package/dist/mailbox-cursor.js +102 -0
- package/dist/mailbox-intent.d.ts +41 -0
- package/{src/mailbox-intent.ts → dist/mailbox-intent.js} +39 -63
- package/dist/mailbox-management.d.ts +240 -0
- package/dist/mailbox-management.js +576 -0
- package/dist/mailbox-presence.d.ts +52 -0
- package/{src/mailbox-presence.ts → dist/mailbox-presence.js} +19 -30
- package/dist/mailbox-queue.d.ts +126 -0
- package/dist/mailbox-queue.js +214 -0
- package/dist/mailbox-sync.d.ts +93 -0
- package/dist/mailbox-sync.js +490 -0
- package/dist/mailbox-upstream.d.ts +38 -0
- package/{src/mailbox-upstream.ts → dist/mailbox-upstream.js} +37 -47
- package/dist/message-category.d.ts +51 -0
- package/{src/message-category.ts → dist/message-category.js} +28 -71
- package/dist/message-move.d.ts +285 -0
- package/dist/message-move.js +858 -0
- package/dist/message-parser.d.ts +14 -0
- package/dist/message-parser.js +17 -0
- package/{src/message-presence.ts → dist/message-presence.d.ts} +1 -13
- package/dist/message-presence.js +29 -0
- package/dist/message-read.d.ts +51 -0
- package/dist/message-read.js +31 -0
- package/dist/message-sync.d.ts +345 -0
- package/dist/message-sync.js +1358 -0
- package/dist/mime-walker.d.ts +102 -0
- package/dist/mime-walker.js +198 -0
- package/dist/organize-job.d.ts +20 -0
- package/dist/organize-job.js +37 -0
- package/dist/outbox-attachment-content.d.ts +37 -0
- package/dist/outbox-attachment-content.js +50 -0
- package/dist/outbox-attachment-filename.d.ts +19 -0
- package/dist/outbox-attachment-filename.js +64 -0
- package/dist/outbox-attachment.d.ts +148 -0
- package/dist/outbox-attachment.js +273 -0
- package/dist/outbox-queue.d.ts +73 -0
- package/dist/outbox-queue.js +284 -0
- package/{src/outbox-status.ts → dist/outbox-status.d.ts} +1 -6
- package/dist/outbox-status.js +18 -0
- package/dist/ownership.d.ts +24 -0
- package/dist/ownership.js +83 -0
- package/dist/placement-move-terminal.d.ts +67 -0
- package/dist/placement-move-terminal.js +88 -0
- package/dist/placement-move.d.ts +83 -0
- package/dist/placement-move.js +216 -0
- package/{src/placement-settled.ts → dist/placement-settled.d.ts} +19 -50
- package/dist/placement-settled.js +75 -0
- package/dist/quarantine.d.ts +95 -0
- package/dist/quarantine.js +131 -0
- package/dist/refusal.d.ts +31 -0
- package/dist/refusal.js +3 -0
- package/{src/restore-source-placement.ts → dist/restore-source-placement.d.ts} +29 -101
- package/dist/restore-source-placement.js +79 -0
- package/dist/search-text-backfill.d.ts +15 -0
- package/dist/search-text-backfill.js +33 -0
- package/dist/snippet.d.ts +61 -0
- package/dist/snippet.js +129 -0
- package/dist/spam-report.d.ts +86 -0
- package/dist/spam-report.js +241 -0
- package/dist/sync-warning.d.ts +21 -0
- package/dist/sync-warning.js +26 -0
- package/dist/sync-watermarks.d.ts +138 -0
- package/dist/sync-watermarks.js +205 -0
- package/dist/test-helpers/fake-imap.d.ts +151 -0
- package/dist/test-helpers/fake-imap.js +508 -0
- package/dist/test-helpers/folder-roles.d.ts +34 -0
- package/dist/test-helpers/folder-roles.js +47 -0
- package/dist/test-helpers/mime-tree-generator.d.ts +82 -0
- package/dist/test-helpers/mime-tree-generator.js +329 -0
- package/dist/test-helpers/test-store.d.ts +69 -0
- package/dist/test-helpers/test-store.js +193 -0
- package/dist/text/normalizer.d.ts +20 -0
- package/dist/text/normalizer.js +102 -0
- package/dist/thread-message-rows.d.ts +69 -0
- package/dist/thread-message-rows.js +40 -0
- package/dist/thread-root.d.ts +7 -0
- package/dist/thread-root.js +25 -0
- package/dist/types.d.ts +343 -0
- package/dist/types.js +19 -0
- package/package.json +86 -73
- package/src/account-credentials.ts +0 -210
- package/src/adversarial-mime.e2e.test.ts +0 -307
- package/src/attribute-mapper.test.ts +0 -104
- package/src/attribute-mapper.ts +0 -148
- package/src/body-parse.test.ts +0 -55
- package/src/body-part-mapper.property.test.ts +0 -354
- package/src/body-part-mapper.ts +0 -360
- package/src/body-sync-auto-read-writeonce.test.ts +0 -219
- package/src/body-sync-calendar-suggestion.test.ts +0 -433
- package/src/body-sync-category-denormalize.test.ts +0 -352
- package/src/body-sync-category-override.test.ts +0 -501
- package/src/body-sync-category-writeonce.test.ts +0 -234
- package/src/body-sync-filter.test.ts +0 -237
- package/src/body-sync-message-gone.test.ts +0 -139
- package/src/body-sync-placement-flags.test.ts +0 -347
- package/src/body-sync-placement-writeonce.test.ts +0 -337
- package/src/body-sync-quarantine.test.ts +0 -276
- package/src/body-sync-queue.test.ts +0 -124
- package/src/body-sync-queue.ts +0 -131
- package/src/body-sync-resync.test.ts +0 -605
- package/src/body-sync-terminal.test.ts +0 -310
- package/src/body-sync-terminal.ts +0 -176
- package/src/body-sync-unclassifiable-once.test.ts +0 -260
- package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
- package/src/body-sync.ts +0 -1970
- package/src/calendar-parts.ts +0 -41
- package/src/classification-backfill.test.ts +0 -615
- package/src/classification-backfill.ts +0 -363
- package/src/classify-message.test.ts +0 -205
- package/src/classify-message.ts +0 -116
- package/src/connection-factory.ts +0 -102
- package/src/connection-test.ts +0 -208
- package/src/credentials.test.ts +0 -181
- package/src/external-move.test.ts +0 -369
- package/src/external-move.ts +0 -272
- package/src/filters/anchor-drift.ts +0 -55
- package/src/filters/filter-config.test.ts +0 -229
- package/src/filters/filter-config.ts +0 -69
- package/src/filters/list-id.test.ts +0 -50
- package/src/filters/match.test.ts +0 -429
- package/src/filters/match.ts +0 -274
- package/src/filters/message-embedder.test.ts +0 -39
- package/src/filters/message-embedder.ts +0 -33
- package/src/filters/pipeline.test.ts +0 -330
- package/src/filters/pipeline.ts +0 -284
- package/src/flag-push-terminal.test.ts +0 -209
- package/src/flag-push.test.ts +0 -408
- package/src/flag-push.ts +0 -198
- package/src/flag-queue-stars.test.ts +0 -171
- package/src/flag-queue.ts +0 -509
- package/src/folder-placement.ts +0 -56
- package/src/heuristics/authenticationResults.test.ts +0 -189
- package/src/heuristics/authenticationResults.ts +0 -212
- package/src/heuristics/classifyByHeaders.test.ts +0 -889
- package/src/heuristics/classifyByHeaders.ts +0 -467
- package/src/heuristics/classifyPlacement.test.ts +0 -366
- package/src/heuristics/classifyPlacement.ts +0 -211
- package/src/heuristics/classifyRealMail.test.ts +0 -242
- package/src/heuristics/machineSenders.test.ts +0 -101
- package/src/heuristics/senderMismatch.test.ts +0 -346
- package/src/heuristics/senderMismatch.ts +0 -259
- package/src/imapflow-connect-retry.test.ts +0 -134
- package/src/imapflow-connection.e2e.test.ts +0 -335
- package/src/imapflow-connection.integ.test.ts +0 -316
- package/src/imapflow-connection.test.ts +0 -469
- package/src/imapflow-connection.ts +0 -1475
- package/src/index.ts +0 -350
- package/src/list-id-backfill.test.ts +0 -573
- package/src/list-id-backfill.ts +0 -274
- package/src/mailbox-cursor-rebuild.test.ts +0 -171
- package/src/mailbox-cursor-rebuild.ts +0 -146
- package/src/mailbox-cursor.test.ts +0 -249
- package/src/mailbox-delete-intent.test.ts +0 -246
- package/src/mailbox-management.test.ts +0 -356
- package/src/mailbox-management.ts +0 -839
- package/src/mailbox-presence.test.ts +0 -72
- package/src/mailbox-queue.ts +0 -415
- package/src/mailbox-rename-intent.test.ts +0 -389
- package/src/mailbox-rename-subtree.test.ts +0 -664
- package/src/mailbox-sync.test.ts +0 -848
- package/src/mailbox-sync.ts +0 -724
- package/src/message-delete-junk-reconcile.test.ts +0 -117
- package/src/message-delete-no-trash.test.ts +0 -245
- package/src/message-delete-unsettled-placement.test.ts +0 -396
- package/src/message-move-copy.test.ts +0 -562
- package/src/message-move-empty-trash.test.ts +0 -326
- package/src/message-move-same-mailbox.test.ts +0 -139
- package/src/message-move-unsettled-placement.test.ts +0 -378
- package/src/message-move.ts +0 -1348
- package/src/message-parser.ts +0 -26
- package/src/message-presence.test.ts +0 -50
- package/src/message-sync-address-spoof.test.ts +0 -293
- package/src/message-sync-category.test.ts +0 -142
- package/src/message-sync-changedsince.test.ts +0 -840
- package/src/message-sync-flags.test.ts +0 -158
- package/src/message-sync-junk-harvest.test.ts +0 -244
- package/src/message-sync-quarantine.test.ts +0 -416
- package/src/message-sync-repoint.test.ts +0 -419
- package/src/message-sync.ts +0 -1963
- package/src/mime-walker.test.ts +0 -311
- package/src/mime-walker.ts +0 -305
- package/src/outbox-attachment-content.test.ts +0 -153
- package/src/outbox-attachment-content.ts +0 -104
- package/src/outbox-attachment-filename.test.ts +0 -132
- package/src/outbox-attachment-filename.ts +0 -76
- package/src/outbox-attachment.test.ts +0 -738
- package/src/outbox-attachment.ts +0 -536
- package/src/outbox-failed-recovery.test.ts +0 -370
- package/src/outbox-queue-settled-row.test.ts +0 -140
- package/src/outbox-queue.test.ts +0 -291
- package/src/outbox-queue.ts +0 -508
- package/src/pass-through-unit-of-work.ts +0 -17
- package/src/placement-move-terminal.test.ts +0 -300
- package/src/placement-move-terminal.ts +0 -148
- package/src/placement-move-unsettled.test.ts +0 -275
- package/src/placement-move.ts +0 -336
- package/src/placement-settled.test.ts +0 -147
- package/src/placement-transition-race.test.ts +0 -482
- package/src/quarantine.test.ts +0 -191
- package/src/quarantine.ts +0 -228
- package/src/snippet.test.ts +0 -213
- package/src/snippet.ts +0 -152
- package/src/spam-report.test.ts +0 -801
- package/src/spam-report.ts +0 -357
- package/src/stale-message-reconcile.test.ts +0 -82
- package/src/stale-message-reconcile.ts +0 -55
- package/src/sync-watermarks.test.ts +0 -409
- package/src/sync-watermarks.ts +0 -319
- package/src/system-flag-wire-format.test.ts +0 -77
- package/src/test-helpers/folder-roles.ts +0 -66
- package/src/test-helpers/isolated-mailbox.ts +0 -44
- package/src/test-helpers/mailfuzz-connection.ts +0 -27
- package/src/test-helpers/mime-tree-generator.ts +0 -461
- package/src/text/normalizer.test.ts +0 -223
- package/src/text/normalizer.ts +0 -140
- package/src/thread-message-rows.ts +0 -77
- package/src/types/mailparser-augment.d.ts +0 -15
- package/src/types/natural-porter-stemmer.d.ts +0 -50
- package/src/types/stopword.d.ts +0 -69
- package/src/types.ts +0 -371
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
- package/tsconfig.json +0 -8
package/src/body-part-mapper.ts
DELETED
|
@@ -1,360 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pair every persisted `BodyPart` row with the decoded bytes mailparser
|
|
3
|
-
* produced for the same MIME node. Total function: returns one
|
|
4
|
-
* `BodyPartContentPair` per non-multipart leaf, never undefined, never
|
|
5
|
-
* throws on well-formed input.
|
|
6
|
-
*
|
|
7
|
-
* Source of truth (per issue #395):
|
|
8
|
-
* - `partPath`, `contentType`, `contentId`, `dispositionFilename`,
|
|
9
|
-
* `sizeOctets` — owned by BODYSTRUCTURE (the `BodyPart` row).
|
|
10
|
-
* - Decoded bytes — owned by mailparser (`parsed.text`, `parsed.html`,
|
|
11
|
-
* `parsed.attachments[i].content`).
|
|
12
|
-
* - `parsed.attachments[i].partId` is also dot-numbered (set at runtime
|
|
13
|
-
* in `mail-parser.js:860`; missing from `@types/mailparser`; surfaced
|
|
14
|
-
* here via `./types/mailparser-augment.d.ts`). Structural pairing on
|
|
15
|
-
* `partId === partPath` is the dominant strategy for non-text leaves.
|
|
16
|
-
*
|
|
17
|
-
* Pipeline (per leaf in declaration order; each step consumes one
|
|
18
|
-
* attachment when it pairs, subsequent steps work on the residual set):
|
|
19
|
-
* 1. text/plain → first leaf gets `parsed.text` (empty Buffer if
|
|
20
|
-
* `sizeOctets === 0`); subsequent text/plain leaves get empty.
|
|
21
|
-
* 2. text/html → first leaf gets `parsed.html` (empty Buffer if
|
|
22
|
-
* `sizeOctets === 0`); subsequent text/html leaves get empty.
|
|
23
|
-
* 3. Non-text leaves with `sizeOctets === 0` short-circuit to an empty
|
|
24
|
-
* Buffer without consuming an attachment — symmetric with the text
|
|
25
|
-
* path, so the positional fallback can't hand a zero-sized leaf real
|
|
26
|
-
* bytes that belong to another leaf.
|
|
27
|
-
* 4. Remaining non-text leaves try, in order:
|
|
28
|
-
* a. `attachment.partId === bodyPart.partPath`
|
|
29
|
-
* b. contentId match (case + angle-insensitive)
|
|
30
|
-
* c. dispositionFilename match (case-insensitive)
|
|
31
|
-
* d. content-type compatibility class: exact → binary-family
|
|
32
|
-
* (any non-text/*) → text-family
|
|
33
|
-
* e. positional fallback: next unconsumed attachment in declaration
|
|
34
|
-
* order. Logs at `debug` so operators can see "we guessed"
|
|
35
|
-
* pairings without spamming higher levels.
|
|
36
|
-
* 5. Anything still unpaired → zero-byte Buffer + structured `warn` log.
|
|
37
|
-
*
|
|
38
|
-
* The mapper assumes well-formed input — malformed MIME is rejected
|
|
39
|
-
* upstream in `mime-walker.ts`. If a leaf truly has no source bytes
|
|
40
|
-
* available (e.g. mailparser tolerated a malformed message that
|
|
41
|
-
* BODYSTRUCTURE disagrees with on leaf count), it gets the empty-Buffer
|
|
42
|
-
* fallback rather than a throw, so the call site stays simple.
|
|
43
|
-
*/
|
|
44
|
-
|
|
45
|
-
/// <reference path="./types/mailparser-augment.d.ts" />
|
|
46
|
-
|
|
47
|
-
import { Buffer } from "node:buffer";
|
|
48
|
-
import type { BodyPartItem } from "@remit/data-ports";
|
|
49
|
-
import type { Attachment, ParsedMail } from "mailparser";
|
|
50
|
-
|
|
51
|
-
export interface BodyPartContentPair {
|
|
52
|
-
partPath: string;
|
|
53
|
-
contentType: string;
|
|
54
|
-
content: Buffer;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export type MapperInput = Pick<
|
|
58
|
-
BodyPartItem,
|
|
59
|
-
| "partPath"
|
|
60
|
-
| "isMultipart"
|
|
61
|
-
| "mediaType"
|
|
62
|
-
| "mediaSubtype"
|
|
63
|
-
| "contentId"
|
|
64
|
-
| "dispositionFilename"
|
|
65
|
-
| "disposition"
|
|
66
|
-
> & {
|
|
67
|
-
/**
|
|
68
|
-
* Bytes the BODYSTRUCTURE leaf declares. Optional so hand-built test
|
|
69
|
-
* inputs don't have to fabricate it — in production every BodyPart row
|
|
70
|
-
* carries this field. `sizeOctets === 0` is the explicit empty-leaf
|
|
71
|
-
* signal that text-leaf routing uses to produce a zero-byte Buffer
|
|
72
|
-
* (see fixture 15-empty-text-part).
|
|
73
|
-
*/
|
|
74
|
-
sizeOctets?: number;
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
export interface MapperLogger {
|
|
78
|
-
warn(obj: Record<string, unknown>, msg: string): void;
|
|
79
|
-
debug?(obj: Record<string, unknown>, msg: string): void;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export interface MapperContext {
|
|
83
|
-
messageId?: string;
|
|
84
|
-
logger?: MapperLogger;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const EMPTY_BUFFER = Buffer.alloc(0);
|
|
88
|
-
|
|
89
|
-
const stripAngles = (value: string): string => value.replace(/^<+|>+$/g, "");
|
|
90
|
-
|
|
91
|
-
const buildContentType = (
|
|
92
|
-
part: Pick<BodyPartItem, "mediaType" | "mediaSubtype">,
|
|
93
|
-
): string => `${part.mediaType.toLowerCase()}/${part.mediaSubtype}`;
|
|
94
|
-
|
|
95
|
-
const textToBuffer = (value: string | false | undefined): Buffer => {
|
|
96
|
-
if (typeof value !== "string" || value.length === 0) return EMPTY_BUFFER;
|
|
97
|
-
return Buffer.from(value, "utf8");
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
const isTextStar = (contentType: string): boolean =>
|
|
101
|
-
contentType.toLowerCase().startsWith("text/");
|
|
102
|
-
|
|
103
|
-
/** Match `partId === partPath` against an unconsumed attachment. */
|
|
104
|
-
const findByPartId = (
|
|
105
|
-
attachments: readonly Attachment[],
|
|
106
|
-
consumed: ReadonlySet<number>,
|
|
107
|
-
partPath: string,
|
|
108
|
-
): number | null => {
|
|
109
|
-
for (let i = 0; i < attachments.length; i++) {
|
|
110
|
-
if (consumed.has(i)) continue;
|
|
111
|
-
const att = attachments[i];
|
|
112
|
-
if (typeof att.partId !== "string") continue;
|
|
113
|
-
if (att.partId === partPath) return i;
|
|
114
|
-
}
|
|
115
|
-
return null;
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
/** Match by contentId (case + angle-insensitive). */
|
|
119
|
-
const findByContentId = (
|
|
120
|
-
attachments: readonly Attachment[],
|
|
121
|
-
consumed: ReadonlySet<number>,
|
|
122
|
-
contentId: string,
|
|
123
|
-
): number | null => {
|
|
124
|
-
const target = stripAngles(contentId).toLowerCase();
|
|
125
|
-
for (let i = 0; i < attachments.length; i++) {
|
|
126
|
-
if (consumed.has(i)) continue;
|
|
127
|
-
const att = attachments[i];
|
|
128
|
-
const candidates = [att.cid, att.contentId];
|
|
129
|
-
for (const cand of candidates) {
|
|
130
|
-
if (typeof cand !== "string") continue;
|
|
131
|
-
if (stripAngles(cand).toLowerCase() === target) return i;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
return null;
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
/** Match by dispositionFilename (case-insensitive). */
|
|
138
|
-
const findByFilename = (
|
|
139
|
-
attachments: readonly Attachment[],
|
|
140
|
-
consumed: ReadonlySet<number>,
|
|
141
|
-
filename: string,
|
|
142
|
-
): number | null => {
|
|
143
|
-
const target = filename.toLowerCase();
|
|
144
|
-
for (let i = 0; i < attachments.length; i++) {
|
|
145
|
-
if (consumed.has(i)) continue;
|
|
146
|
-
const att = attachments[i];
|
|
147
|
-
if (typeof att.filename !== "string") continue;
|
|
148
|
-
if (att.filename.toLowerCase() === target) return i;
|
|
149
|
-
}
|
|
150
|
-
return null;
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Content-type compatibility class. The BodyPart row's content-type is the
|
|
155
|
-
* sender's verbatim label; mailparser's `att.contentType` is sniffed from
|
|
156
|
-
* filename extension. They often disagree (the Odido bug — postmortem
|
|
157
|
-
* #394 — is `application/octet-stream` vs `application/pdf`).
|
|
158
|
-
*
|
|
159
|
-
* Order:
|
|
160
|
-
* 1. Exact match (`application/pdf` ↔ `application/pdf`).
|
|
161
|
-
* 2. Binary family — leaf is non-`text/*`, attachment is non-`text/*`.
|
|
162
|
-
* Covers octet-stream ↔ pdf, octet-stream ↔ image, etc.
|
|
163
|
-
* 3. Text family — leaf is `text/*`, attachment is `text/*`. Catches
|
|
164
|
-
* `text/calendar` / `text/csv` after the inline `text/plain` and
|
|
165
|
-
* `text/html` slots are taken.
|
|
166
|
-
*/
|
|
167
|
-
const findByContentTypeClass = (
|
|
168
|
-
attachments: readonly Attachment[],
|
|
169
|
-
consumed: ReadonlySet<number>,
|
|
170
|
-
contentType: string,
|
|
171
|
-
): number | null => {
|
|
172
|
-
const target = contentType.toLowerCase();
|
|
173
|
-
const leafIsText = isTextStar(target);
|
|
174
|
-
|
|
175
|
-
for (let i = 0; i < attachments.length; i++) {
|
|
176
|
-
if (consumed.has(i)) continue;
|
|
177
|
-
const att = attachments[i];
|
|
178
|
-
if (att.contentType?.toLowerCase() === target) return i;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
if (!leafIsText) {
|
|
182
|
-
for (let i = 0; i < attachments.length; i++) {
|
|
183
|
-
if (consumed.has(i)) continue;
|
|
184
|
-
const att = attachments[i];
|
|
185
|
-
const attType = att.contentType?.toLowerCase();
|
|
186
|
-
if (!attType) continue;
|
|
187
|
-
if (!isTextStar(attType)) return i;
|
|
188
|
-
}
|
|
189
|
-
} else {
|
|
190
|
-
for (let i = 0; i < attachments.length; i++) {
|
|
191
|
-
if (consumed.has(i)) continue;
|
|
192
|
-
const att = attachments[i];
|
|
193
|
-
const attType = att.contentType?.toLowerCase();
|
|
194
|
-
if (!attType) continue;
|
|
195
|
-
if (isTextStar(attType)) return i;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
return null;
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
/** Positional fallback: next unconsumed attachment in declaration order. */
|
|
203
|
-
const findPositional = (
|
|
204
|
-
attachments: readonly Attachment[],
|
|
205
|
-
consumed: ReadonlySet<number>,
|
|
206
|
-
): number | null => {
|
|
207
|
-
for (let i = 0; i < attachments.length; i++) {
|
|
208
|
-
if (!consumed.has(i)) return i;
|
|
209
|
-
}
|
|
210
|
-
return null;
|
|
211
|
-
};
|
|
212
|
-
|
|
213
|
-
const pairNonTextLeaf = (
|
|
214
|
-
part: MapperInput,
|
|
215
|
-
attachments: readonly Attachment[],
|
|
216
|
-
consumed: Set<number>,
|
|
217
|
-
context: MapperContext,
|
|
218
|
-
): { content: Buffer; paired: true } | { paired: false } => {
|
|
219
|
-
const contentType = buildContentType(part);
|
|
220
|
-
|
|
221
|
-
// Symmetry with the text-leaf path: a leaf whose BODYSTRUCTURE-declared
|
|
222
|
-
// size is zero has no bytes to find, so do not consume a residual
|
|
223
|
-
// attachment for it (the positional fallback would otherwise hand it
|
|
224
|
-
// real bytes belonging to another leaf — see PR D, review note b).
|
|
225
|
-
if (part.sizeOctets === 0) {
|
|
226
|
-
return { content: EMPTY_BUFFER, paired: true };
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const byPartId = findByPartId(attachments, consumed, part.partPath);
|
|
230
|
-
if (byPartId !== null) {
|
|
231
|
-
consumed.add(byPartId);
|
|
232
|
-
return { content: attachments[byPartId].content, paired: true };
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (part.contentId) {
|
|
236
|
-
const byCid = findByContentId(attachments, consumed, part.contentId);
|
|
237
|
-
if (byCid !== null) {
|
|
238
|
-
consumed.add(byCid);
|
|
239
|
-
return { content: attachments[byCid].content, paired: true };
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
if (part.dispositionFilename) {
|
|
244
|
-
const byName = findByFilename(
|
|
245
|
-
attachments,
|
|
246
|
-
consumed,
|
|
247
|
-
part.dispositionFilename,
|
|
248
|
-
);
|
|
249
|
-
if (byName !== null) {
|
|
250
|
-
consumed.add(byName);
|
|
251
|
-
return { content: attachments[byName].content, paired: true };
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const byType = findByContentTypeClass(attachments, consumed, contentType);
|
|
256
|
-
if (byType !== null) {
|
|
257
|
-
consumed.add(byType);
|
|
258
|
-
return { content: attachments[byType].content, paired: true };
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
const positional = findPositional(attachments, consumed);
|
|
262
|
-
if (positional !== null) {
|
|
263
|
-
consumed.add(positional);
|
|
264
|
-
const chosen = attachments[positional];
|
|
265
|
-
// Audit log so operators can see "we guessed" pairings when a
|
|
266
|
-
// well-formed message takes the structural-fallback path. Helps
|
|
267
|
-
// triage cases where mailparser and BODYSTRUCTURE disagree more
|
|
268
|
-
// than the earlier matchers can handle.
|
|
269
|
-
context.logger?.debug?.(
|
|
270
|
-
{
|
|
271
|
-
messageId: context.messageId,
|
|
272
|
-
partPath: part.partPath,
|
|
273
|
-
leafContentType: contentType,
|
|
274
|
-
chosenAttachmentPartId: chosen.partId ?? null,
|
|
275
|
-
chosenAttachmentContentType: chosen.contentType ?? null,
|
|
276
|
-
chosenAttachmentFilename: chosen.filename ?? null,
|
|
277
|
-
},
|
|
278
|
-
"body-part-mapper: positional fallback paired leaf with next residual attachment",
|
|
279
|
-
);
|
|
280
|
-
return { content: chosen.content, paired: true };
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
return { paired: false };
|
|
284
|
-
};
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Pair body-part rows with mailparser bytes. Returns one pair per
|
|
288
|
-
* non-multipart leaf, in the order the leaves appear in `bodyParts`.
|
|
289
|
-
*
|
|
290
|
-
* Never throws on well-formed input. Leaves with no source bytes get an
|
|
291
|
-
* empty Buffer; the call site does not need a try/catch.
|
|
292
|
-
*/
|
|
293
|
-
export const mapBodyPartsToContent = (
|
|
294
|
-
bodyParts: readonly MapperInput[],
|
|
295
|
-
parsed: ParsedMail,
|
|
296
|
-
context: MapperContext = {},
|
|
297
|
-
): BodyPartContentPair[] => {
|
|
298
|
-
const pairs: BodyPartContentPair[] = [];
|
|
299
|
-
const attachments = parsed.attachments ?? [];
|
|
300
|
-
const consumed = new Set<number>();
|
|
301
|
-
let textRouted = false;
|
|
302
|
-
let htmlRouted = false;
|
|
303
|
-
|
|
304
|
-
for (const part of bodyParts) {
|
|
305
|
-
if (part.isMultipart) continue;
|
|
306
|
-
|
|
307
|
-
const contentType = buildContentType(part);
|
|
308
|
-
const isPlain = part.mediaType === "TEXT" && part.mediaSubtype === "plain";
|
|
309
|
-
const isHtml = part.mediaType === "TEXT" && part.mediaSubtype === "html";
|
|
310
|
-
|
|
311
|
-
// `sizeOctets === 0` is the explicit empty-leaf signal; an undefined
|
|
312
|
-
// sizeOctets (hand-built test input) is treated as non-empty so we
|
|
313
|
-
// fall through to the text-routing path.
|
|
314
|
-
const isEmptyLeaf = part.sizeOctets === 0;
|
|
315
|
-
|
|
316
|
-
if (isPlain) {
|
|
317
|
-
const content =
|
|
318
|
-
!textRouted && !isEmptyLeaf ? textToBuffer(parsed.text) : EMPTY_BUFFER;
|
|
319
|
-
textRouted = true;
|
|
320
|
-
pairs.push({ partPath: part.partPath, contentType, content });
|
|
321
|
-
continue;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
if (isHtml) {
|
|
325
|
-
const content =
|
|
326
|
-
!htmlRouted && !isEmptyLeaf
|
|
327
|
-
? textToBuffer(
|
|
328
|
-
typeof parsed.html === "string" ? parsed.html : undefined,
|
|
329
|
-
)
|
|
330
|
-
: EMPTY_BUFFER;
|
|
331
|
-
htmlRouted = true;
|
|
332
|
-
pairs.push({ partPath: part.partPath, contentType, content });
|
|
333
|
-
continue;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const result = pairNonTextLeaf(part, attachments, consumed, context);
|
|
337
|
-
if (result.paired) {
|
|
338
|
-
pairs.push({
|
|
339
|
-
partPath: part.partPath,
|
|
340
|
-
contentType,
|
|
341
|
-
content: result.content,
|
|
342
|
-
});
|
|
343
|
-
continue;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
context.logger?.warn(
|
|
347
|
-
{
|
|
348
|
-
messageId: context.messageId,
|
|
349
|
-
partPath: part.partPath,
|
|
350
|
-
contentType,
|
|
351
|
-
contentId: part.contentId,
|
|
352
|
-
filename: part.dispositionFilename,
|
|
353
|
-
},
|
|
354
|
-
"body-part-mapper: no source bytes for leaf; pairing with zero-byte buffer",
|
|
355
|
-
);
|
|
356
|
-
pairs.push({ partPath: part.partPath, contentType, content: EMPTY_BUFFER });
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
return pairs;
|
|
360
|
-
};
|
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Issue #499: `Address.flags.unsubscribed` auto-mark-read is a first-
|
|
3
|
-
* classification decision, like `Message.category` (#355) and the placement
|
|
4
|
-
* verdict (#383). Re-deriving it on the two shipped re-entrant paths —
|
|
5
|
-
* `fetchAndGetBody`'s `NoSuchKey` fallback and `syncBodies(..., force: true)`
|
|
6
|
-
* — undid a user who had deliberately marked such a message unread.
|
|
7
|
-
*
|
|
8
|
-
* The re-entrant fixtures below keep `flags.unsubscribed` set, so a regression
|
|
9
|
-
* that drops the guard shows up as a real `FlagQueueService.markAsRead`
|
|
10
|
-
* round-trip against a message the user owns, not as a fixture that happens to
|
|
11
|
-
* disagree with the flag.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import assert from "node:assert/strict";
|
|
15
|
-
import { Readable } from "node:stream";
|
|
16
|
-
import { describe, it } from "node:test";
|
|
17
|
-
import type {
|
|
18
|
-
AddressItem,
|
|
19
|
-
IAddressRepository,
|
|
20
|
-
IEnvelopeRepository,
|
|
21
|
-
IMessageRepository,
|
|
22
|
-
IThreadMessageRepository,
|
|
23
|
-
MessageItem,
|
|
24
|
-
UpdateMessageInput,
|
|
25
|
-
} from "@remit/data-ports";
|
|
26
|
-
import type { StorageService } from "@remit/storage-service";
|
|
27
|
-
import { BodySyncService } from "./body-sync.js";
|
|
28
|
-
import type { FlagQueueService } from "./flag-queue.js";
|
|
29
|
-
import type { IImapConnection } from "./types.js";
|
|
30
|
-
|
|
31
|
-
const UNSUBSCRIBED_SENDER = "newsletter@example.com";
|
|
32
|
-
|
|
33
|
-
const PLAIN_EML = Buffer.from(
|
|
34
|
-
[
|
|
35
|
-
`From: Newsletter <${UNSUBSCRIBED_SENDER}>`,
|
|
36
|
-
"To: me@example.com",
|
|
37
|
-
"Subject: This week",
|
|
38
|
-
"List-Unsubscribe: <mailto:stop@example.com>",
|
|
39
|
-
"Content-Type: text/plain",
|
|
40
|
-
"",
|
|
41
|
-
"body",
|
|
42
|
-
].join("\r\n"),
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
interface MarkReadCall {
|
|
46
|
-
accountConfigId: string;
|
|
47
|
-
messageId: string;
|
|
48
|
-
accountId: string;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
interface Harness {
|
|
52
|
-
service: BodySyncService;
|
|
53
|
-
markReadCalls: MarkReadCall[];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const buildHarness = (
|
|
57
|
-
message: Partial<MessageItem> & Pick<MessageItem, "messageId">,
|
|
58
|
-
retrieve: () => Promise<Buffer>,
|
|
59
|
-
): Harness => {
|
|
60
|
-
const markReadCalls: MarkReadCall[] = [];
|
|
61
|
-
|
|
62
|
-
const messageRow = {
|
|
63
|
-
uid: 1,
|
|
64
|
-
mailboxId: "mb-inbox",
|
|
65
|
-
...message,
|
|
66
|
-
} as unknown as MessageItem;
|
|
67
|
-
|
|
68
|
-
const messageService = {
|
|
69
|
-
get: async () => messageRow,
|
|
70
|
-
update: async (_messageId: string, input: UpdateMessageInput) => {
|
|
71
|
-
Object.assign(messageRow, input);
|
|
72
|
-
},
|
|
73
|
-
} as unknown as IMessageRepository;
|
|
74
|
-
|
|
75
|
-
const threadMessageService = {
|
|
76
|
-
findAllByMessageId: async () => [
|
|
77
|
-
{
|
|
78
|
-
threadMessageId: "tm-1",
|
|
79
|
-
messageId: message.messageId,
|
|
80
|
-
mailboxId: messageRow.mailboxId,
|
|
81
|
-
sentDate: 1,
|
|
82
|
-
isRead: false,
|
|
83
|
-
isDeleted: false,
|
|
84
|
-
hasStars: false,
|
|
85
|
-
hasAttachment: false,
|
|
86
|
-
},
|
|
87
|
-
],
|
|
88
|
-
update: async () => {},
|
|
89
|
-
} as unknown as IThreadMessageRepository;
|
|
90
|
-
|
|
91
|
-
const storageService = {
|
|
92
|
-
retrieve,
|
|
93
|
-
storeMessageBody: async () => ({ uri: `s3://bodies/${message.messageId}` }),
|
|
94
|
-
storeMessageBodyStream: async () => ({
|
|
95
|
-
uri: `s3://bodies/${message.messageId}`,
|
|
96
|
-
}),
|
|
97
|
-
storeParsedBody: async () => {},
|
|
98
|
-
listBodyParts: async () => [],
|
|
99
|
-
} as unknown as StorageService;
|
|
100
|
-
|
|
101
|
-
const addressService = {
|
|
102
|
-
getAddress: async () =>
|
|
103
|
-
({
|
|
104
|
-
flags: { unsubscribed: { value: true, setAt: 1 } },
|
|
105
|
-
}) as unknown as AddressItem,
|
|
106
|
-
incrementInboundCount: async () => {},
|
|
107
|
-
} as unknown as IAddressRepository;
|
|
108
|
-
|
|
109
|
-
const envelopeService = {
|
|
110
|
-
listBodyParts: async () => [],
|
|
111
|
-
} as unknown as IEnvelopeRepository;
|
|
112
|
-
|
|
113
|
-
const flagQueueService = {
|
|
114
|
-
markAsRead: async (
|
|
115
|
-
accountConfigId: string,
|
|
116
|
-
messageId: string,
|
|
117
|
-
accountId: string,
|
|
118
|
-
) => {
|
|
119
|
-
markReadCalls.push({ accountConfigId, messageId, accountId });
|
|
120
|
-
},
|
|
121
|
-
} as unknown as FlagQueueService;
|
|
122
|
-
|
|
123
|
-
const service = new BodySyncService(
|
|
124
|
-
messageService,
|
|
125
|
-
storageService,
|
|
126
|
-
threadMessageService,
|
|
127
|
-
addressService,
|
|
128
|
-
envelopeService,
|
|
129
|
-
{ info: () => {}, error: () => {} },
|
|
130
|
-
undefined,
|
|
131
|
-
undefined,
|
|
132
|
-
undefined,
|
|
133
|
-
{ flagQueueService },
|
|
134
|
-
);
|
|
135
|
-
|
|
136
|
-
return { service, markReadCalls };
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
const noSuchKeyError = () =>
|
|
140
|
-
Object.assign(new Error("missing"), { name: "NoSuchKey" });
|
|
141
|
-
|
|
142
|
-
const bodyConnection = () =>
|
|
143
|
-
({
|
|
144
|
-
openBox: async () => {},
|
|
145
|
-
fetchMessageBody: async () => PLAIN_EML,
|
|
146
|
-
async *fetchMessageBodies(uids: number[]) {
|
|
147
|
-
for (const uid of uids) {
|
|
148
|
-
yield { uid, source: Readable.from([PLAIN_EML]) };
|
|
149
|
-
}
|
|
150
|
-
},
|
|
151
|
-
}) as unknown as IImapConnection;
|
|
152
|
-
|
|
153
|
-
describe("unsubscribed auto-mark-read is decided once (issue #499)", () => {
|
|
154
|
-
it("marks a message read on the pass that first classifies it", async () => {
|
|
155
|
-
const harness = buildHarness({ messageId: "m-1" }, async () => {
|
|
156
|
-
throw new Error("no body stored yet; must not retrieve");
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
await harness.service.fetchAndGetBody(
|
|
160
|
-
"m-1",
|
|
161
|
-
"acc-1",
|
|
162
|
-
"cfg-1",
|
|
163
|
-
{ fullPath: "INBOX", placement: "inbox" },
|
|
164
|
-
async () => bodyConnection(),
|
|
165
|
-
);
|
|
166
|
-
|
|
167
|
-
assert.deepEqual(harness.markReadCalls, [
|
|
168
|
-
{ accountConfigId: "cfg-1", messageId: "m-1", accountId: "acc-1" },
|
|
169
|
-
]);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("leaves a manually-unread message alone through the NoSuchKey IMAP re-fetch", async () => {
|
|
173
|
-
const harness = buildHarness(
|
|
174
|
-
{ messageId: "m-1", bodyStorageKey: "s3://bodies/m-1" },
|
|
175
|
-
async () => {
|
|
176
|
-
throw noSuchKeyError();
|
|
177
|
-
},
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
await harness.service.fetchAndGetBody(
|
|
181
|
-
"m-1",
|
|
182
|
-
"acc-1",
|
|
183
|
-
"cfg-1",
|
|
184
|
-
{ fullPath: "INBOX", placement: "inbox" },
|
|
185
|
-
async () => bodyConnection(),
|
|
186
|
-
);
|
|
187
|
-
|
|
188
|
-
assert.deepEqual(
|
|
189
|
-
harness.markReadCalls,
|
|
190
|
-
[],
|
|
191
|
-
"a re-entrant pass must not re-apply auto-read over the user's unread",
|
|
192
|
-
);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it("leaves a manually-unread message alone when syncBodies re-fetches with force", async () => {
|
|
196
|
-
const harness = buildHarness(
|
|
197
|
-
{ messageId: "m-1", bodyStorageKey: "s3://bodies/m-1" },
|
|
198
|
-
async () => {
|
|
199
|
-
throw new Error("force path must not retrieve from storage");
|
|
200
|
-
},
|
|
201
|
-
);
|
|
202
|
-
|
|
203
|
-
const result = await harness.service.syncBodies(
|
|
204
|
-
["m-1"],
|
|
205
|
-
"acc-1",
|
|
206
|
-
"cfg-1",
|
|
207
|
-
{ fullPath: "INBOX", placement: "inbox" },
|
|
208
|
-
async () => bodyConnection(),
|
|
209
|
-
true,
|
|
210
|
-
);
|
|
211
|
-
|
|
212
|
-
assert.deepEqual(result.syncedMessageIds, ["m-1"]);
|
|
213
|
-
assert.deepEqual(
|
|
214
|
-
harness.markReadCalls,
|
|
215
|
-
[],
|
|
216
|
-
"a forced re-sync must not re-apply auto-read over the user's unread",
|
|
217
|
-
);
|
|
218
|
-
});
|
|
219
|
-
});
|