@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
|
@@ -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>& < > " </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(/ /g, " ")
|
|
144
|
+
.replace(/&/g, "&")
|
|
145
|
+
.replace(/</g, "<")
|
|
146
|
+
.replace(/>/g, ">")
|
|
147
|
+
.replace(/"/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
|
+
});
|