@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
@@ -0,0 +1,224 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
3
+ import type {
4
+ IMessagePlacementMoveRepository,
5
+ IMessageRepository,
6
+ IThreadMessageRepository,
7
+ } from "@remit/data-ports";
8
+ import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
9
+ import { createQueueProducer } from "@remit/sqs-client/producer";
10
+
11
+ /**
12
+ * Event the reconciler (imap-worker `handlePlacementMovePush`) drains. Carries
13
+ * ONLY our own message id — never a UID (epic #1281 invariant 1). The UID is
14
+ * resolved fresh from the Message row at push time, so the event stays valid
15
+ * across any amount of queue delay or an unrelated UIDVALIDITY rebuild
16
+ * (#1272).
17
+ */
18
+ export interface PlacementMovePushEvent {
19
+ type: "PLACEMENT_MOVE_PUSH";
20
+ eventId: string;
21
+ timestamp: number;
22
+ accountId: string;
23
+ accountConfigId: string;
24
+ messageId: string;
25
+ }
26
+
27
+ export interface PlacementMoveLogger {
28
+ info(obj: Record<string, unknown>, msg: string): void;
29
+ error(obj: Record<string, unknown>, msg: string): void;
30
+ }
31
+
32
+ const noopLogger: PlacementMoveLogger = {
33
+ info: () => {},
34
+ error: () => {},
35
+ };
36
+
37
+ export interface PlacementMoveConfig {
38
+ messageService: IMessageRepository;
39
+ threadMessageService: IThreadMessageRepository;
40
+ markerService: IMessagePlacementMoveRepository;
41
+ sqsQueueUrl: string;
42
+ sqsEndpoint?: string;
43
+ logger?: PlacementMoveLogger;
44
+ }
45
+
46
+ /**
47
+ * Local-first mover for a classification-driven placement move (issue #1271,
48
+ * epic #1281). Distinct from {@link MessageMoveService} (user-initiated
49
+ * moves/deletes/copies), which is untouched by this fix — the epic's UID
50
+ * staleness invariant (1) only applies to the NEW reconciler this class
51
+ * feeds; generalizing it to every move type is out of scope here.
52
+ *
53
+ * `moveMessage` matches {@link MessageMoveService.moveMessage}'s signature so
54
+ * `BodySyncService`'s existing rescue/demote call site and its tests need no
55
+ * shape changes beyond the dependency's name.
56
+ *
57
+ * Ordering is the fix: every step below (marker, ThreadMessage, Message row,
58
+ * SQS enqueue) completes — or the whole call rejects — BEFORE the caller
59
+ * (`BodySyncService.storeStreamedBody`) writes `bodyStorageKey`. A rejection
60
+ * here therefore always happens before the body-sync skip-guard can trip, so
61
+ * a retry reprocesses the message from scratch. Nothing here is swallowed —
62
+ * let it crash, same as any other infra failure (an SQS enqueue failure is a
63
+ * serious operational failure, not a routine blip: it propagates so a
64
+ * repeated failure reaches the alarmed message-mgmt DLQ, never absorbed).
65
+ *
66
+ * Recovery is driven by the marker's explicit state engine (`pending ->
67
+ * queued -> processing -> processed`, `MessagePlacementMoveState`), not by
68
+ * comparing the message's current location against the requested
69
+ * destination — that comparison alone cannot tell "already confirmed" apart
70
+ * from "local move landed but the enqueue never did" (the defect this issue
71
+ * originally fixes). A retry always re-fetches the marker and drives it
72
+ * forward from whichever state it is actually in.
73
+ */
74
+ export class PlacementMoveService {
75
+ private messageService: IMessageRepository;
76
+ private threadMessageService: IThreadMessageRepository;
77
+ private markerService: IMessagePlacementMoveRepository;
78
+ private sqs: SQSClient;
79
+ private queueUrl: string;
80
+ private log: PlacementMoveLogger;
81
+
82
+ constructor(config: PlacementMoveConfig) {
83
+ this.messageService = config.messageService;
84
+ this.threadMessageService = config.threadMessageService;
85
+ this.markerService = config.markerService;
86
+ this.queueUrl = config.sqsQueueUrl;
87
+ this.log = config.logger ?? noopLogger;
88
+ this.sqs = createQueueProducer({
89
+ queueUrl: config.sqsQueueUrl,
90
+ endpoint: config.sqsEndpoint,
91
+ });
92
+ }
93
+
94
+ moveMessage = async (
95
+ accountConfigId: string,
96
+ messageId: string,
97
+ destinationMailboxId: string,
98
+ accountId: string,
99
+ ): Promise<void> => {
100
+ const message = await this.messageService.get(messageId);
101
+ const sourceMailboxId = message.mailboxId;
102
+
103
+ // Recovery: a marker already exists for THIS message and destination —
104
+ // from an earlier (possibly partially-failed) call. The marker's STATE,
105
+ // not a comparison against the message's current location, decides what
106
+ // happens next (PR #1289 review finding 1 + design amendment: an
107
+ // explicit pending -> queued -> processing -> processed engine).
108
+ const existingMarker = await this.markerService.find(messageId);
109
+ if (
110
+ existingMarker &&
111
+ existingMarker.destinationMailboxId === destinationMailboxId
112
+ ) {
113
+ if (existingMarker.state === "pending") {
114
+ // Local move + marker already committed; only the enqueue is
115
+ // missing (or a further retry after the enqueue itself failed
116
+ // again). Drive the SAME marker forward — never re-derive the
117
+ // local move, which already happened.
118
+ await this.enqueuePush({ accountId, accountConfigId, messageId });
119
+ await this.markerService.updateState(messageId, "queued");
120
+ this.log.info(
121
+ { messageId, accountId, destinationMailboxId },
122
+ "Pending marker survived an earlier enqueue failure; re-enqueued and advanced to queued",
123
+ );
124
+ }
125
+ // queued / processing / processed already has its own driver in
126
+ // flight (the SQS message, or the reconciler currently running) —
127
+ // nothing to do here either way.
128
+ return;
129
+ }
130
+
131
+ // Genuine no-op: nothing pending for this destination, and the message
132
+ // is already there (a duplicate verdict recomputed after a confirmed
133
+ // move).
134
+ if (sourceMailboxId === destinationMailboxId) return;
135
+
136
+ // Fresh move. Strictly sequential — nothing here is parallel: the local
137
+ // move (marker + ThreadMessage + Message row) fully commits BEFORE the
138
+ // queue kick runs. `put` is idempotent and always resets `state` back to
139
+ // `pending`, since a fresh decision starts a new lifecycle regardless of
140
+ // what a stale row (different destination) held.
141
+ await this.markerService.put({
142
+ messageId,
143
+ accountId,
144
+ accountConfigId,
145
+ sourceMailboxId,
146
+ destinationMailboxId,
147
+ });
148
+
149
+ await this.updateThreadMessageMailbox(
150
+ accountConfigId,
151
+ messageId,
152
+ destinationMailboxId,
153
+ );
154
+
155
+ await this.messageService.updateForMove(messageId, {
156
+ mailboxId: destinationMailboxId,
157
+ status: MessageStatus.moving,
158
+ syncStatus: MessageSyncStatus.pending,
159
+ originalMailboxId: sourceMailboxId,
160
+ originalUid: message.uid,
161
+ });
162
+
163
+ // The queue kick is a serious operational step, not a routine one — a
164
+ // failure here MUST propagate (never swallowed): the marker stays
165
+ // `pending`, the surrounding body-sync call fails, SQS redelivers, and
166
+ // a repeated failure reaches the alarmed message-mgmt DLQ. The state
167
+ // engine makes this *recoverable* (the recovery branch above drives a
168
+ // surviving `pending` marker forward on the next call) — it must never
169
+ // make it *quiet*.
170
+ await this.enqueuePush({ accountId, accountConfigId, messageId });
171
+ await this.markerService.updateState(messageId, "queued");
172
+
173
+ this.log.info(
174
+ { messageId, accountId, sourceMailboxId, destinationMailboxId },
175
+ "Placement move applied locally; pending marker queued for IMAP push",
176
+ );
177
+ };
178
+
179
+ private updateThreadMessageMailbox = async (
180
+ accountConfigId: string,
181
+ messageId: string,
182
+ destinationMailboxId: string,
183
+ ): Promise<void> => {
184
+ const threadMessage = await this.threadMessageService.getByMessageId(
185
+ accountConfigId,
186
+ messageId,
187
+ );
188
+
189
+ await this.threadMessageService.update(
190
+ threadMessage.accountConfigId,
191
+ threadMessage.threadMessageId,
192
+ { mailboxId: destinationMailboxId },
193
+ {
194
+ composites: {
195
+ sentDate: threadMessage.sentDate,
196
+ mailboxId: threadMessage.mailboxId,
197
+ isRead: threadMessage.isRead,
198
+ isDeleted: threadMessage.isDeleted,
199
+ hasStars: threadMessage.hasStars,
200
+ hasAttachment: threadMessage.hasAttachment,
201
+ },
202
+ },
203
+ );
204
+ };
205
+
206
+ private enqueuePush = async (params: {
207
+ accountId: string;
208
+ accountConfigId: string;
209
+ messageId: string;
210
+ }): Promise<void> => {
211
+ const event: PlacementMovePushEvent = {
212
+ type: "PLACEMENT_MOVE_PUSH",
213
+ eventId: randomUUID(),
214
+ timestamp: Date.now(),
215
+ ...params,
216
+ };
217
+ await this.sqs.send(
218
+ new SendMessageCommand({
219
+ QueueUrl: this.queueUrl,
220
+ MessageBody: JSON.stringify(event),
221
+ }),
222
+ );
223
+ };
224
+ }
@@ -0,0 +1,213 @@
1
+ import assert from "node:assert";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ extractSnippetFromEmail,
5
+ generateSnippet,
6
+ normalizeSubject,
7
+ removeQuotedContent,
8
+ } from "./snippet.js";
9
+
10
+ describe("removeQuotedContent", () => {
11
+ it("removes content after > quote marker", () => {
12
+ const text = "Hello\nWorld\n> quoted line\nmore quoted";
13
+ assert.strictEqual(removeQuotedContent(text), "Hello\nWorld");
14
+ });
15
+
16
+ it("removes content after 'On ... wrote:' pattern", () => {
17
+ const text = "Hello\nOn Monday, John wrote:\nQuoted content";
18
+ assert.strictEqual(removeQuotedContent(text), "Hello");
19
+ });
20
+
21
+ it("removes content after original message separator", () => {
22
+ const text = "Hello\n--- Original Message ---\nQuoted content";
23
+ assert.strictEqual(removeQuotedContent(text), "Hello");
24
+ });
25
+
26
+ it("removes content after Outlook underline separator", () => {
27
+ const text = "Hello\n___\nQuoted content";
28
+ assert.strictEqual(removeQuotedContent(text), "Hello");
29
+ });
30
+
31
+ it("removes content after Outlook forward header", () => {
32
+ const text = "Hello\nFrom: John Sent: Today To: Jane\nQuoted content";
33
+ assert.strictEqual(removeQuotedContent(text), "Hello");
34
+ });
35
+
36
+ it("returns full text when no quote markers", () => {
37
+ const text = "Hello\nWorld\nNo quotes here";
38
+ assert.strictEqual(removeQuotedContent(text), text);
39
+ });
40
+ });
41
+
42
+ describe("normalizeSubject", () => {
43
+ describe("prefix removal", () => {
44
+ it("removes Re: prefix", () => {
45
+ const result = normalizeSubject("Re: Meeting tomorrow");
46
+ assert.ok(!result.includes("re"));
47
+ assert.ok(result.includes("meet"));
48
+ });
49
+
50
+ it("removes Fwd: prefix", () => {
51
+ const result = normalizeSubject("Fwd: Important document");
52
+ assert.ok(!result.includes("fwd"));
53
+ });
54
+
55
+ it("removes numbered Re: prefix", () => {
56
+ const result = normalizeSubject("Re[3]: Meeting tomorrow");
57
+ assert.ok(!result.includes("re"));
58
+ });
59
+
60
+ it("removes multiple prefixes", () => {
61
+ const result = normalizeSubject("Re: Fwd: Re: Original subject");
62
+ assert.ok(!result.includes("re"));
63
+ assert.ok(!result.includes("fwd"));
64
+ });
65
+
66
+ it("removes German Aw: prefix", () => {
67
+ const result = normalizeSubject("Aw: Besprechung morgen");
68
+ assert.ok(!result.includes("aw"));
69
+ });
70
+
71
+ it("removes Portuguese Res: prefix", () => {
72
+ const result = normalizeSubject("Res: Reunião amanhã");
73
+ assert.ok(!result.includes("res"));
74
+ });
75
+ });
76
+
77
+ describe("language detection", () => {
78
+ it("auto-detects English and uses English stemming", () => {
79
+ const result = normalizeSubject(
80
+ "Meeting tomorrow about running the project quickly",
81
+ );
82
+ // Should stem "running" to "run", "quickly" to "quick"
83
+ assert.ok(result.includes("run") || result.includes("quick"));
84
+ });
85
+
86
+ it("auto-detects German and uses German processing", () => {
87
+ const result = normalizeSubject(
88
+ "Besprechung über die Verbindung und den Projektstand morgen",
89
+ );
90
+ // Should process with German stemmer
91
+ assert.ok(result.length > 0);
92
+ });
93
+
94
+ it("auto-detects French and uses French processing", () => {
95
+ const result = normalizeSubject(
96
+ "Réunion demain matin pour discuter du projet",
97
+ );
98
+ // Should preserve French accented characters and process
99
+ assert.ok(result.includes("réunion") || result.includes("reunion"));
100
+ });
101
+
102
+ it("uses provided language override", () => {
103
+ const result = normalizeSubject("Meeting tomorrow", "en");
104
+ assert.ok(result.includes("meet"));
105
+ });
106
+ });
107
+
108
+ describe("normalization", () => {
109
+ it("removes stopwords", () => {
110
+ const result = normalizeSubject("The meeting is about the project");
111
+ // "the" and "is" should be removed
112
+ assert.ok(!result.split(" ").includes("the"));
113
+ });
114
+
115
+ it("handles Unicode characters", () => {
116
+ const result = normalizeSubject("Réunion à Paris");
117
+ // Should not strip accented characters
118
+ assert.ok(result.includes("réunion") || result.includes("reunion"));
119
+ assert.ok(result.includes("pari"));
120
+ });
121
+
122
+ it("deduplicates consecutive words", () => {
123
+ const result = normalizeSubject("Meeting meeting meeting update");
124
+ // After stemming, should dedupe
125
+ const words = result.split(" ");
126
+ for (let i = 1; i < words.length; i++) {
127
+ assert.notStrictEqual(
128
+ words[i],
129
+ words[i - 1],
130
+ "Should not have consecutive duplicates",
131
+ );
132
+ }
133
+ });
134
+ });
135
+ });
136
+
137
+ describe("generateSnippet", () => {
138
+ it("generates snippet from text", () => {
139
+ const text = "Hello, this is a test message.";
140
+ assert.strictEqual(generateSnippet(text), text);
141
+ });
142
+
143
+ it("removes quoted content", () => {
144
+ const text = "Hello\n> quoted line";
145
+ assert.strictEqual(generateSnippet(text), "Hello");
146
+ });
147
+
148
+ it("normalizes whitespace", () => {
149
+ const text = "Hello world\n\nmultiple spaces";
150
+ assert.strictEqual(generateSnippet(text), "Hello world multiple spaces");
151
+ });
152
+
153
+ it("truncates long text at word boundary", () => {
154
+ const text = "The quick brown fox jumps over the lazy dog repeatedly";
155
+ const result = generateSnippet(text, 30);
156
+ assert.ok(result.length <= 30);
157
+ assert.ok(result.endsWith("..."));
158
+ });
159
+
160
+ it("truncates single long word", () => {
161
+ const text = "supercalifragilisticexpialidocious";
162
+ const result = generateSnippet(text, 20);
163
+ assert.ok(result.endsWith("..."));
164
+ // When no word boundary found, truncates at maxLength-1 + "..."
165
+ // so result is at most maxLength + 2 (for the extra chars in "...")
166
+ assert.ok(result.length <= 22);
167
+ });
168
+ });
169
+
170
+ describe("extractSnippetFromEmail", () => {
171
+ it("prefers plain text over HTML", () => {
172
+ const result = extractSnippetFromEmail("Plain text", "<p>HTML</p>");
173
+ assert.strictEqual(result, "Plain text");
174
+ });
175
+
176
+ it("falls back to HTML when no plain text", () => {
177
+ const result = extractSnippetFromEmail(undefined, "<p>HTML content</p>");
178
+ assert.ok(result.includes("HTML content"));
179
+ });
180
+
181
+ it("strips HTML tags", () => {
182
+ const result = extractSnippetFromEmail(
183
+ undefined,
184
+ "<p><strong>Bold</strong> text</p>",
185
+ );
186
+ assert.ok(!result.includes("<"));
187
+ assert.ok(!result.includes(">"));
188
+ assert.ok(result.includes("Bold"));
189
+ assert.ok(result.includes("text"));
190
+ });
191
+
192
+ it("removes script and style tags", () => {
193
+ const html = "<style>body{}</style><script>alert(1)</script><p>Content</p>";
194
+ const result = extractSnippetFromEmail(undefined, html);
195
+ assert.ok(!result.includes("body"));
196
+ assert.ok(!result.includes("alert"));
197
+ assert.ok(result.includes("Content"));
198
+ });
199
+
200
+ it("decodes HTML entities", () => {
201
+ const html = "<p>&amp; &lt; &gt; &quot; &nbsp;</p>";
202
+ const result = extractSnippetFromEmail(undefined, html);
203
+ assert.ok(result.includes("&"));
204
+ assert.ok(result.includes("<"));
205
+ assert.ok(result.includes(">"));
206
+ assert.ok(result.includes('"'));
207
+ });
208
+
209
+ it("returns empty string when no content", () => {
210
+ const result = extractSnippetFromEmail(undefined, undefined);
211
+ assert.strictEqual(result, "");
212
+ });
213
+ });
package/src/snippet.ts ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Snippet generation utilities for email threading.
3
+ *
4
+ * Extracts preview text from email bodies by removing quoted content
5
+ * and normalizing whitespace.
6
+ */
7
+
8
+ import {
9
+ createTextNormalizer,
10
+ type SupportedLanguage,
11
+ } from "./text/normalizer.js";
12
+
13
+ /**
14
+ * Remove quoted reply content from email body.
15
+ *
16
+ * Stops at common quote indicators:
17
+ * - Lines starting with ">"
18
+ * - "On ... wrote:" patterns
19
+ * - "--- Original Message ---" separators
20
+ * - Outlook-style underline separators
21
+ */
22
+ export const removeQuotedContent = (text: string): string => {
23
+ const lines = text.split("\n");
24
+ const result: string[] = [];
25
+
26
+ for (const line of lines) {
27
+ // Stop at quote indicators
28
+ if (/^>/.test(line)) break;
29
+ if (/^On .+ wrote:$/i.test(line)) break;
30
+ if (/^-{3,}\s*Original Message\s*-{3,}$/i.test(line)) break;
31
+ if (/^_{3,}$/.test(line)) break; // Outlook separator
32
+ if (/^From:.*Sent:.*To:/i.test(line)) break; // Outlook forward header
33
+
34
+ result.push(line);
35
+ }
36
+
37
+ return result.join("\n");
38
+ };
39
+
40
+ const normalizer = createTextNormalizer();
41
+
42
+ /**
43
+ * Normalize subject for thread grouping and search.
44
+ *
45
+ * Processing steps:
46
+ * 1. Remove Re:/Fwd:/etc prefixes (multi-language support)
47
+ * 2. Auto-detect language or use provided language
48
+ * 3. Normalize with stemming and stopword removal
49
+ * 4. Deduplicate consecutive words
50
+ *
51
+ * Handles common prefixes across languages:
52
+ * - Re, Fwd, Fw (English)
53
+ * - Aw, Sv, Vs (German, Swedish, Danish)
54
+ * - Ref, Rif (Italian, Spanish)
55
+ * - Odp (Polish)
56
+ * - Ynt (Turkish)
57
+ * - Antw (Dutch)
58
+ * - Res (Portuguese)
59
+ *
60
+ * @param subject - The email subject line
61
+ * @param language - Optional language override (auto-detected if not provided)
62
+ */
63
+ export const normalizeSubject = (
64
+ subject: string,
65
+ language?: SupportedLanguage,
66
+ ): string => {
67
+ const SUBJECT_PREFIX_PATTERN =
68
+ /^(\s*(Re|Fwd|Fw|Aw|Sv|Vs|Ref|Rif|Odp|Ynt|Antw|Res)(\[\d+\])?:\s*)+/i;
69
+
70
+ // Step 1: Remove prefixes
71
+ const withoutPrefix = subject.replace(SUBJECT_PREFIX_PATTERN, "").trim();
72
+
73
+ // Step 2: Auto-detect language if not provided
74
+ const detectedLanguage = language ?? normalizer.detectLanguage(withoutPrefix);
75
+
76
+ // Step 3: Normalize with stemming and stopword removal
77
+ return normalizer.normalize(withoutPrefix, {
78
+ language: detectedLanguage,
79
+ stem: true,
80
+ removeStopwords: true,
81
+ });
82
+ };
83
+
84
+ /**
85
+ * Generate a snippet from email text content.
86
+ *
87
+ * @param text - The text content (plain text or stripped HTML)
88
+ * @param maxLength - Maximum snippet length (default 256)
89
+ * @returns Truncated snippet with ellipsis if needed
90
+ */
91
+ export const generateSnippet = (text: string, maxLength = 256): string => {
92
+ // Remove quoted content
93
+ const unquoted = removeQuotedContent(text);
94
+
95
+ // Normalize whitespace: collapse multiple spaces/newlines to single space
96
+ const normalized = unquoted.replace(/\s+/g, " ").trim();
97
+
98
+ if (normalized.length <= maxLength) {
99
+ return normalized;
100
+ }
101
+
102
+ // Truncate at word boundary
103
+ const truncated = normalized.slice(0, maxLength);
104
+ const lastSpace = truncated.lastIndexOf(" ");
105
+
106
+ // If no space found, just truncate
107
+ if (lastSpace === -1) {
108
+ return `${truncated.slice(0, maxLength - 1)}...`;
109
+ }
110
+
111
+ // Truncate at word boundary and add ellipsis
112
+ return `${truncated.slice(0, lastSpace)}...`;
113
+ };
114
+
115
+ /**
116
+ * Extract text content from parsed email for snippet generation.
117
+ *
118
+ * Prefers plain text over HTML. If only HTML is available,
119
+ * the caller should strip HTML tags before calling generateSnippet.
120
+ *
121
+ * @param textContent - Plain text content (may be undefined)
122
+ * @param htmlContent - HTML content (may be undefined)
123
+ * @param maxLength - Maximum snippet length
124
+ * @returns Generated snippet or empty string
125
+ */
126
+ export const extractSnippetFromEmail = (
127
+ textContent: string | undefined,
128
+ htmlContent: string | undefined,
129
+ maxLength = 256,
130
+ ): string => {
131
+ // Prefer plain text
132
+ if (textContent) {
133
+ return generateSnippet(textContent, maxLength);
134
+ }
135
+
136
+ // Fall back to HTML (caller should strip tags)
137
+ if (htmlContent) {
138
+ // Basic HTML tag stripping - for more robust handling, use html-to-text
139
+ const stripped = htmlContent
140
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
141
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
142
+ .replace(/<[^>]+>/g, " ")
143
+ .replace(/&nbsp;/g, " ")
144
+ .replace(/&amp;/g, "&")
145
+ .replace(/&lt;/g, "<")
146
+ .replace(/&gt;/g, ">")
147
+ .replace(/&quot;/g, '"');
148
+ return generateSnippet(stripped, maxLength);
149
+ }
150
+
151
+ return "";
152
+ };
@@ -0,0 +1,82 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type {
4
+ IMessageRepository,
5
+ IThreadMessageRepository,
6
+ } from "@remit/data-ports";
7
+ import {
8
+ reconcileStaleMessage,
9
+ type StaleMessageReconcileDeps,
10
+ } from "./stale-message-reconcile.js";
11
+
12
+ describe("reconcileStaleMessage", () => {
13
+ it("deletes the Message row and every ThreadMessage copy that points at it", async () => {
14
+ const deletedMessages: string[] = [];
15
+ const deletedThreadMessageKeys: Array<{
16
+ accountConfigId: string;
17
+ threadMessageId: string;
18
+ }> = [];
19
+
20
+ const deps: StaleMessageReconcileDeps = {
21
+ messageService: {
22
+ delete: async (messageId: string) => {
23
+ deletedMessages.push(messageId);
24
+ },
25
+ } as unknown as Pick<IMessageRepository, "delete">,
26
+ threadMessageService: {
27
+ findAllByMessageId: async (
28
+ accountConfigId: string,
29
+ messageId: string,
30
+ ) => [
31
+ { accountConfigId, threadMessageId: `tm-inbox-${messageId}` },
32
+ { accountConfigId, threadMessageId: `tm-label-${messageId}` },
33
+ ],
34
+ deleteMany: async (
35
+ keys: Array<{ accountConfigId: string; threadMessageId: string }>,
36
+ ) => {
37
+ deletedThreadMessageKeys.push(...keys);
38
+ },
39
+ } as unknown as Pick<
40
+ IThreadMessageRepository,
41
+ "findAllByMessageId" | "deleteMany"
42
+ >,
43
+ };
44
+
45
+ const result = await reconcileStaleMessage(deps, "cfg-1", "msg-1");
46
+
47
+ assert.equal(result.threadMessagesDeleted, 2);
48
+ assert.deepEqual(deletedMessages, ["msg-1"]);
49
+ assert.deepEqual(deletedThreadMessageKeys, [
50
+ { accountConfigId: "cfg-1", threadMessageId: "tm-inbox-msg-1" },
51
+ { accountConfigId: "cfg-1", threadMessageId: "tm-label-msg-1" },
52
+ ]);
53
+ });
54
+
55
+ it("skips the batch delete (never calls deleteMany with an empty array) when no copies exist", async () => {
56
+ const deletedMessages: string[] = [];
57
+ let deleteManyCalls = 0;
58
+
59
+ const deps: StaleMessageReconcileDeps = {
60
+ messageService: {
61
+ delete: async (messageId: string) => {
62
+ deletedMessages.push(messageId);
63
+ },
64
+ } as unknown as Pick<IMessageRepository, "delete">,
65
+ threadMessageService: {
66
+ findAllByMessageId: async () => [],
67
+ deleteMany: async () => {
68
+ deleteManyCalls++;
69
+ },
70
+ } as unknown as Pick<
71
+ IThreadMessageRepository,
72
+ "findAllByMessageId" | "deleteMany"
73
+ >,
74
+ };
75
+
76
+ const result = await reconcileStaleMessage(deps, "cfg-1", "msg-solo");
77
+
78
+ assert.equal(result.threadMessagesDeleted, 0);
79
+ assert.deepEqual(deletedMessages, ["msg-solo"]);
80
+ assert.equal(deleteManyCalls, 0);
81
+ });
82
+ });