@remit/mailbox-service 0.0.111 → 0.0.112
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/package.json +21 -6
- package/src/body-part-mapper.ts +51 -1
- package/src/body-sync-queue.test.ts +39 -99
- package/src/body-sync-terminal.test.ts +115 -277
- package/src/body-sync-terminal.ts +10 -12
- package/src/body-sync.test.ts +925 -0
- package/src/body-sync.ts +156 -235
- package/src/classification-backfill.test.ts +197 -527
- package/src/classify-message.test.ts +11 -74
- package/src/external-move.test.ts +102 -209
- package/src/external-move.ts +13 -0
- package/src/filter-operations.test.ts +319 -0
- package/src/filter-operations.ts +377 -0
- package/src/filter-update.test.ts +313 -0
- package/src/filters/filter-config.test.ts +55 -197
- package/src/filters/filter-config.ts +4 -39
- package/src/filters/match.test.ts +1 -38
- package/src/filters/match.ts +3 -52
- package/src/filters/pipeline.test.ts +158 -289
- package/src/filters/pipeline.ts +56 -170
- package/src/flag-push-terminal.ts +9 -11
- package/src/flag-push.test.ts +76 -372
- package/src/flag-queue.test.ts +197 -0
- package/src/folder-operations.test.ts +273 -0
- package/src/folder-operations.ts +217 -0
- package/src/forwarded-message.test.ts +122 -0
- package/src/imapflow-connect-retry.test.ts +76 -113
- package/src/imapflow-connection.test.ts +34 -0
- package/src/imapflow-connection.ts +5 -4
- package/src/index.ts +59 -12
- package/src/list-id-backfill.test.ts +133 -499
- package/src/mailbox-cursor.test.ts +81 -189
- package/src/mailbox-delete-intent.test.ts +78 -205
- package/src/mailbox-intent.ts +5 -3
- package/src/mailbox-management.test.ts +107 -220
- package/src/mailbox-management.ts +1 -2
- package/src/mailbox-presence.test.ts +36 -34
- package/src/mailbox-queue.ts +2 -20
- package/src/mailbox-rename-intent.test.ts +156 -325
- package/src/mailbox-rename-subtree.test.ts +205 -582
- package/src/mailbox-sync.test.ts +182 -744
- package/src/message-move.test.ts +726 -0
- package/src/message-move.ts +227 -57
- package/src/message-read.ts +87 -0
- package/src/message-save-retry.test.ts +90 -0
- package/src/message-sync-flags.test.ts +10 -1
- package/src/message-sync.test.ts +762 -0
- package/src/message-sync.ts +409 -239
- package/src/mime-walker.ts +16 -15
- package/src/organize-job.ts +93 -0
- package/src/outbox-attachment.test.ts +97 -209
- package/src/outbox-failed-recovery.test.ts +198 -269
- package/src/outbox-queue.test.ts +24 -0
- package/src/outbox-queue.ts +25 -8
- package/src/ownership.ts +140 -0
- package/src/placement-move-terminal.ts +9 -12
- package/src/placement-move.ts +33 -6
- package/src/placement-settled.ts +28 -4
- package/src/placement.test.ts +404 -0
- package/src/quarantine.test.ts +90 -148
- package/src/quarantine.ts +3 -2
- package/src/refusal.ts +29 -0
- package/src/restore-source-placement.ts +0 -4
- package/src/spam-report.test.ts +238 -710
- package/src/sync-warning.test.ts +213 -0
- package/src/sync-warning.ts +56 -0
- package/src/test-helpers/fake-imap.ts +690 -0
- package/src/test-helpers/test-queue.ts +56 -0
- package/src/test-helpers/test-store.ts +299 -0
- package/src/thread-root.test.ts +29 -0
- package/src/thread-root.ts +51 -0
- package/src/types/mailparser-augment.d.ts +8 -0
- package/src/adversarial-mime.e2e.test.ts +0 -307
- 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-resync.test.ts +0 -605
- package/src/body-sync-unclassifiable-once.test.ts +0 -260
- package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
- package/src/filters/anchor-drift.ts +0 -55
- package/src/filters/message-embedder.test.ts +0 -39
- package/src/filters/message-embedder.ts +0 -33
- package/src/flag-push-terminal.test.ts +0 -209
- package/src/flag-queue-stars.test.ts +0 -171
- package/src/imapflow-connection.e2e.test.ts +0 -335
- package/src/imapflow-connection.integ.test.ts +0 -316
- 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-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-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/outbox-queue-settled-row.test.ts +0 -140
- package/src/pass-through-unit-of-work.ts +0 -17
- package/src/placement-move-terminal.test.ts +0 -300
- package/src/placement-move-unsettled.test.ts +0 -275
- package/src/placement-transition-race.test.ts +0 -482
- package/src/stale-message-reconcile.test.ts +0 -82
- package/src/stale-message-reconcile.ts +0 -55
- package/src/test-helpers/isolated-mailbox.ts +0 -44
- package/src/test-helpers/mailfuzz-connection.ts +0 -27
- 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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SendMessageBatchCommand,
|
|
3
|
+
SendMessageCommand,
|
|
4
|
+
SQSClient,
|
|
5
|
+
} from "@aws-sdk/client-sqs";
|
|
6
|
+
import { type AwsClientStub, mockClient } from "aws-sdk-client-mock";
|
|
7
|
+
|
|
8
|
+
export interface QueuedEvent {
|
|
9
|
+
queueUrl: string;
|
|
10
|
+
body: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let stub: AwsClientStub<SQSClient> | undefined;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Every SQS send from here on, answered by `answer` instead of the wire. One
|
|
17
|
+
* stub per process: a second capture starts from an empty record.
|
|
18
|
+
*/
|
|
19
|
+
export const captureQueue = () => {
|
|
20
|
+
stub ??= mockClient(SQSClient);
|
|
21
|
+
const sqs = stub;
|
|
22
|
+
sqs.reset();
|
|
23
|
+
const sent: QueuedEvent[] = [];
|
|
24
|
+
let answer: () => Promise<unknown> = async () => ({});
|
|
25
|
+
const record = (
|
|
26
|
+
queueUrl: string | undefined,
|
|
27
|
+
bodies: Array<string | undefined>,
|
|
28
|
+
) => {
|
|
29
|
+
for (const body of bodies) {
|
|
30
|
+
sent.push({ queueUrl: queueUrl ?? "", body: JSON.parse(body ?? "{}") });
|
|
31
|
+
}
|
|
32
|
+
return answer();
|
|
33
|
+
};
|
|
34
|
+
sqs
|
|
35
|
+
.on(SendMessageCommand)
|
|
36
|
+
.callsFake((input) => record(input.QueueUrl, [input.MessageBody]));
|
|
37
|
+
sqs.on(SendMessageBatchCommand).callsFake((input) =>
|
|
38
|
+
record(
|
|
39
|
+
input.QueueUrl,
|
|
40
|
+
(input.Entries ?? []).map(
|
|
41
|
+
(entry: { MessageBody?: string }) => entry.MessageBody,
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
return {
|
|
46
|
+
sent,
|
|
47
|
+
inputs: () =>
|
|
48
|
+
sqs.commandCalls(SendMessageCommand).map((call) => call.args[0].input),
|
|
49
|
+
answer: (next: () => Promise<unknown>) => {
|
|
50
|
+
answer = next;
|
|
51
|
+
},
|
|
52
|
+
restore: () => sqs.reset(),
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type QueueCapture = ReturnType<typeof captureQueue>;
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type {
|
|
6
|
+
AccountItem,
|
|
7
|
+
CreateMessageInput,
|
|
8
|
+
CreateThreadMessageInput,
|
|
9
|
+
MailboxItem,
|
|
10
|
+
MailboxSpecialUseValue,
|
|
11
|
+
MessageItem,
|
|
12
|
+
ThreadMessageItem,
|
|
13
|
+
} from "@remit/data-ports";
|
|
14
|
+
import { AccountAuthType, ConnectionState } from "@remit/domain-enums";
|
|
15
|
+
import {
|
|
16
|
+
createSqliteRepositories,
|
|
17
|
+
openSqliteDatabases,
|
|
18
|
+
} from "@remit/drizzle-service";
|
|
19
|
+
import { applyShippedMigrations } from "@remit/drizzle-service/test-sqlite";
|
|
20
|
+
import type { StorageService } from "@remit/storage-service";
|
|
21
|
+
import { createFilesystemStorageService } from "@remit/storage-service/filesystem";
|
|
22
|
+
import Database from "better-sqlite3";
|
|
23
|
+
import { MessageSyncService } from "../message-sync.js";
|
|
24
|
+
import { SyncWarningService } from "../sync-warning.js";
|
|
25
|
+
import type { FakeImapServer } from "./fake-imap.js";
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
captureQueue,
|
|
29
|
+
type QueueCapture,
|
|
30
|
+
type QueuedEvent,
|
|
31
|
+
} from "./test-queue.js";
|
|
32
|
+
|
|
33
|
+
const UPLOAD_URLS = {
|
|
34
|
+
origin: "https://reader.test",
|
|
35
|
+
signingSecret: "test-signing-secret",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The store the app opens: the committed migrations applied to a file, then
|
|
40
|
+
* that file opened the way the app opens it — as the app, mail and search
|
|
41
|
+
* database at once — with every repo the app composes.
|
|
42
|
+
* Storage made from it lives in the same directory and goes with it.
|
|
43
|
+
*/
|
|
44
|
+
let migrated: string | undefined;
|
|
45
|
+
|
|
46
|
+
const migratedTemplate = (): string => {
|
|
47
|
+
if (migrated) return migrated;
|
|
48
|
+
const dir = mkdtempSync(join(tmpdir(), "mbx-template-"));
|
|
49
|
+
const filename = join(dir, "template.sqlite");
|
|
50
|
+
const migrating = new Database(filename);
|
|
51
|
+
migrating.pragma("synchronous = OFF");
|
|
52
|
+
applyShippedMigrations(migrating);
|
|
53
|
+
migrating.close();
|
|
54
|
+
process.once("exit", () => rmSync(dir, { recursive: true, force: true }));
|
|
55
|
+
migrated = filename;
|
|
56
|
+
return filename;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const openStore = async () => {
|
|
60
|
+
const dir = mkdtempSync(join(tmpdir(), "mbx-store-"));
|
|
61
|
+
const filename = join(dir, "store.sqlite");
|
|
62
|
+
copyFileSync(migratedTemplate(), filename);
|
|
63
|
+
const { close, ...databases } = await openSqliteDatabases({
|
|
64
|
+
app: filename,
|
|
65
|
+
mail: filename,
|
|
66
|
+
search: filename,
|
|
67
|
+
});
|
|
68
|
+
let storages = 0;
|
|
69
|
+
return {
|
|
70
|
+
...createSqliteRepositories(databases),
|
|
71
|
+
storage: (): StorageService =>
|
|
72
|
+
createFilesystemStorageService(
|
|
73
|
+
join(dir, `storage-${++storages}`),
|
|
74
|
+
UPLOAD_URLS,
|
|
75
|
+
),
|
|
76
|
+
brokenStorage: (): StorageService => {
|
|
77
|
+
const root = join(dir, `broken-${++storages}`);
|
|
78
|
+
writeFileSync(root, "");
|
|
79
|
+
return createFilesystemStorageService(root);
|
|
80
|
+
},
|
|
81
|
+
close: async () => {
|
|
82
|
+
await close();
|
|
83
|
+
rmSync(dir, { recursive: true, force: true });
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type Store = Awaited<ReturnType<typeof openStore>>;
|
|
89
|
+
|
|
90
|
+
export const seedAccount = async (
|
|
91
|
+
store: Store,
|
|
92
|
+
overrides: Partial<AccountItem> = {},
|
|
93
|
+
): Promise<AccountItem> => {
|
|
94
|
+
const config = await store.accountConfig.create({
|
|
95
|
+
accountConfigId: overrides.accountConfigId,
|
|
96
|
+
userId: "user-1",
|
|
97
|
+
name: "Me",
|
|
98
|
+
});
|
|
99
|
+
return store.account.create({
|
|
100
|
+
username: "me@example.com",
|
|
101
|
+
email: "me@example.com",
|
|
102
|
+
authType: AccountAuthType.Password,
|
|
103
|
+
passwordHash: '{"ciphertext":"x"}',
|
|
104
|
+
imapHost: "imap.example.com",
|
|
105
|
+
imapPort: 993,
|
|
106
|
+
imapTls: true,
|
|
107
|
+
imapStartTls: false,
|
|
108
|
+
isActive: true,
|
|
109
|
+
connectionState: ConnectionState.Authenticated,
|
|
110
|
+
...overrides,
|
|
111
|
+
accountConfigId: config.accountConfigId,
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export const seedMailbox = async (
|
|
116
|
+
store: Store,
|
|
117
|
+
account: Pick<AccountItem, "accountId">,
|
|
118
|
+
fullPath: string,
|
|
119
|
+
options: {
|
|
120
|
+
specialUse?: MailboxSpecialUseValue;
|
|
121
|
+
overrides?: Partial<MailboxItem>;
|
|
122
|
+
} = {},
|
|
123
|
+
): Promise<MailboxItem> => {
|
|
124
|
+
const created = await store.mailbox.create({
|
|
125
|
+
accountId: account.accountId,
|
|
126
|
+
namespacePrefix: "",
|
|
127
|
+
hierarchyDelimiter: "/",
|
|
128
|
+
fullPath,
|
|
129
|
+
uidValidity: 7,
|
|
130
|
+
uidNext: 1,
|
|
131
|
+
highestModseq: "0",
|
|
132
|
+
messageCount: 0,
|
|
133
|
+
unseenCount: 0,
|
|
134
|
+
deletedCount: 0,
|
|
135
|
+
totalSize: 0,
|
|
136
|
+
lastSyncUid: 0,
|
|
137
|
+
highWaterMarkUid: 0,
|
|
138
|
+
lastMessageSyncAt: 0,
|
|
139
|
+
...(options.specialUse ? { specialUse: [options.specialUse] } : {}),
|
|
140
|
+
...options.overrides,
|
|
141
|
+
});
|
|
142
|
+
if (created.outcome !== "Created") {
|
|
143
|
+
throw new Error(`${fullPath} already exists`);
|
|
144
|
+
}
|
|
145
|
+
if (options.specialUse) {
|
|
146
|
+
await store.mailboxSpecialUse.create(
|
|
147
|
+
created.mailbox.mailboxId,
|
|
148
|
+
options.specialUse,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return created.mailbox;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export interface SeededMessage {
|
|
155
|
+
message: MessageItem;
|
|
156
|
+
threadMessage: ThreadMessageItem;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const seedMessage = async (
|
|
160
|
+
store: Store,
|
|
161
|
+
account: Pick<AccountItem, "accountConfigId">,
|
|
162
|
+
mailbox: Pick<MailboxItem, "mailboxId">,
|
|
163
|
+
uid: number,
|
|
164
|
+
overrides: {
|
|
165
|
+
message?: Partial<CreateMessageInput>;
|
|
166
|
+
threadMessage?: Partial<CreateThreadMessageInput>;
|
|
167
|
+
} = {},
|
|
168
|
+
): Promise<SeededMessage> => {
|
|
169
|
+
const messageId = overrides.message?.messageId ?? randomUUID();
|
|
170
|
+
const message = await store.message.create({
|
|
171
|
+
messageId,
|
|
172
|
+
mailboxId: mailbox.mailboxId,
|
|
173
|
+
uid,
|
|
174
|
+
sequenceNumber: uid,
|
|
175
|
+
rfc822Size: 100,
|
|
176
|
+
internalDate: 1_700_000_000_000 + uid,
|
|
177
|
+
envelopeId: randomUUID(),
|
|
178
|
+
rootBodyPartId: randomUUID(),
|
|
179
|
+
status: "active",
|
|
180
|
+
syncStatus: "synced",
|
|
181
|
+
...overrides.message,
|
|
182
|
+
});
|
|
183
|
+
const threadMessage = await store.threadMessage.create({
|
|
184
|
+
accountConfigId: account.accountConfigId,
|
|
185
|
+
threadId: messageId,
|
|
186
|
+
messageId,
|
|
187
|
+
mailboxId: message.mailboxId,
|
|
188
|
+
uid: message.uid,
|
|
189
|
+
referenceOrder: 0,
|
|
190
|
+
internalDate: message.internalDate,
|
|
191
|
+
sentDate: message.internalDate,
|
|
192
|
+
isRead: false,
|
|
193
|
+
isDeleted: false,
|
|
194
|
+
hasAttachment: false,
|
|
195
|
+
hasStars: false,
|
|
196
|
+
...overrides.threadMessage,
|
|
197
|
+
});
|
|
198
|
+
return { message, threadMessage };
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export const silentLog = {
|
|
202
|
+
info: () => {},
|
|
203
|
+
warn: () => {},
|
|
204
|
+
error: () => {},
|
|
205
|
+
debug: () => {},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export const syncFolder = async (
|
|
209
|
+
store: Store,
|
|
210
|
+
account: Pick<AccountItem, "accountId" | "accountConfigId">,
|
|
211
|
+
mailbox: Pick<MailboxItem, "mailboxId">,
|
|
212
|
+
imap: FakeImapServer,
|
|
213
|
+
): Promise<string[]> => {
|
|
214
|
+
const connection = imap.connection();
|
|
215
|
+
const service = new MessageSyncService(
|
|
216
|
+
{ getConnection: () => connection, close: async () => {} },
|
|
217
|
+
store.mailbox,
|
|
218
|
+
store.mailboxSpecialUse,
|
|
219
|
+
store.message,
|
|
220
|
+
store.envelope,
|
|
221
|
+
store.address,
|
|
222
|
+
store.threadMessage,
|
|
223
|
+
new SyncWarningService(store.syncWarning),
|
|
224
|
+
silentLog,
|
|
225
|
+
store.flagPush,
|
|
226
|
+
store.messageFlag,
|
|
227
|
+
);
|
|
228
|
+
const synced: string[] = [];
|
|
229
|
+
for (;;) {
|
|
230
|
+
const result = await service.syncMessages(
|
|
231
|
+
mailbox.mailboxId,
|
|
232
|
+
account.accountId,
|
|
233
|
+
account.accountConfigId,
|
|
234
|
+
);
|
|
235
|
+
synced.push(...result.syncedMessageIds);
|
|
236
|
+
if (!result.hasMore) return synced;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
export const messageAtUid = async (
|
|
241
|
+
store: Store,
|
|
242
|
+
mailbox: Pick<MailboxItem, "mailboxId">,
|
|
243
|
+
uid: number,
|
|
244
|
+
): Promise<MessageItem> => {
|
|
245
|
+
const found = (await store.message.listAllByMailbox(mailbox.mailboxId)).find(
|
|
246
|
+
(message) => message.uid === uid,
|
|
247
|
+
);
|
|
248
|
+
if (!found) throw new Error(`no message at uid ${uid}`);
|
|
249
|
+
return found;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
export const arriveAt = async (
|
|
253
|
+
store: Store,
|
|
254
|
+
imap: FakeImapServer,
|
|
255
|
+
account: Pick<AccountItem, "accountId" | "accountConfigId">,
|
|
256
|
+
mailbox: Pick<MailboxItem, "mailboxId" | "fullPath">,
|
|
257
|
+
raw: Buffer,
|
|
258
|
+
): Promise<string> => {
|
|
259
|
+
const uid = imap.deliver(mailbox.fullPath, raw);
|
|
260
|
+
await syncFolder(store, account, mailbox, imap);
|
|
261
|
+
return (await messageAtUid(store, mailbox, uid)).messageId;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
export const storeBody = async (
|
|
265
|
+
store: Store,
|
|
266
|
+
storage: StorageService,
|
|
267
|
+
account: Pick<AccountItem, "accountId" | "accountConfigId">,
|
|
268
|
+
messageId: string,
|
|
269
|
+
raw: Buffer,
|
|
270
|
+
): Promise<void> => {
|
|
271
|
+
const ref = await storage.storeMessageBody({
|
|
272
|
+
accountConfigId: account.accountConfigId,
|
|
273
|
+
accountId: account.accountId,
|
|
274
|
+
messageId,
|
|
275
|
+
content: raw,
|
|
276
|
+
});
|
|
277
|
+
await store.message.update(messageId, { bodyStorageKey: ref.uri });
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Another writer landing between a service's read and its write: `action` runs,
|
|
282
|
+
* for real, the next time `method` is called on `target`, and the call then
|
|
283
|
+
* proceeds as normal.
|
|
284
|
+
*/
|
|
285
|
+
export const beforeNext = <T extends object, K extends keyof T>(
|
|
286
|
+
target: T,
|
|
287
|
+
method: K,
|
|
288
|
+
action: () => Promise<unknown>,
|
|
289
|
+
): void => {
|
|
290
|
+
const original = target[method] as unknown as (...args: unknown[]) => unknown;
|
|
291
|
+
let pending = true;
|
|
292
|
+
target[method] = (async (...args: unknown[]) => {
|
|
293
|
+
if (pending) {
|
|
294
|
+
pending = false;
|
|
295
|
+
await action();
|
|
296
|
+
}
|
|
297
|
+
return original.apply(target, args);
|
|
298
|
+
}) as T[K];
|
|
299
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { threadRootOf } from "./thread-root.js";
|
|
4
|
+
|
|
5
|
+
const headers = (inReplyTo = "") => ({
|
|
6
|
+
messageId: "<self@example.com>",
|
|
7
|
+
inReplyTo,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe("threadRootOf", () => {
|
|
11
|
+
it("reads a doubled References root in its wire spelling", () => {
|
|
12
|
+
assert.equal(
|
|
13
|
+
threadRootOf(headers("<p@example.com>"), ["<<root@example.com>>"], "m"),
|
|
14
|
+
"<root@example.com>",
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("falls back to a doubled In-Reply-To, then to the message itself", () => {
|
|
19
|
+
assert.equal(
|
|
20
|
+
threadRootOf(headers("<<p@example.com>>"), undefined, "m"),
|
|
21
|
+
"<p@example.com>",
|
|
22
|
+
);
|
|
23
|
+
assert.equal(threadRootOf(headers(), undefined, "m"), "<self@example.com>");
|
|
24
|
+
assert.equal(
|
|
25
|
+
threadRootOf({ messageId: "<>", inReplyTo: "" }, [], "m"),
|
|
26
|
+
"m",
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { MessageReferenceInput } from "@remit/data-ports";
|
|
2
|
+
import {
|
|
3
|
+
bareMessageId,
|
|
4
|
+
isValidMessageId,
|
|
5
|
+
wireMessageId,
|
|
6
|
+
} from "@remit/data-ports/id";
|
|
7
|
+
import { ReferenceType } from "@remit/domain-enums";
|
|
8
|
+
import type { ImapEnvelope } from "./types.js";
|
|
9
|
+
|
|
10
|
+
type ThreadingHeaders = Pick<ImapEnvelope, "messageId" | "inReplyTo">;
|
|
11
|
+
|
|
12
|
+
const firstUsable = (
|
|
13
|
+
candidates: readonly (string | undefined)[],
|
|
14
|
+
): string | undefined => {
|
|
15
|
+
const found = candidates.find(isValidMessageId);
|
|
16
|
+
return found === undefined ? undefined : wireMessageId(found);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const threadRootOf = (
|
|
20
|
+
envelope: ThreadingHeaders,
|
|
21
|
+
references: readonly string[] | undefined,
|
|
22
|
+
messageId: string,
|
|
23
|
+
): string =>
|
|
24
|
+
firstUsable([references?.[0], envelope.inReplyTo, envelope.messageId]) ??
|
|
25
|
+
messageId;
|
|
26
|
+
|
|
27
|
+
export const parentOf = (
|
|
28
|
+
envelope: ThreadingHeaders,
|
|
29
|
+
references: readonly string[] | undefined,
|
|
30
|
+
): string | undefined =>
|
|
31
|
+
firstUsable([envelope.inReplyTo, references?.[references.length - 1]]);
|
|
32
|
+
|
|
33
|
+
export const messageReferencesOf = (
|
|
34
|
+
envelope: ThreadingHeaders,
|
|
35
|
+
references: readonly string[] | undefined,
|
|
36
|
+
): MessageReferenceInput[] => [
|
|
37
|
+
...(isValidMessageId(envelope.inReplyTo)
|
|
38
|
+
? [
|
|
39
|
+
{
|
|
40
|
+
messageIdValue: bareMessageId(envelope.inReplyTo),
|
|
41
|
+
referenceType: ReferenceType.InReplyTo,
|
|
42
|
+
referenceOrder: 0,
|
|
43
|
+
},
|
|
44
|
+
]
|
|
45
|
+
: []),
|
|
46
|
+
...(references ?? []).filter(isValidMessageId).map((reference, index) => ({
|
|
47
|
+
messageIdValue: bareMessageId(reference),
|
|
48
|
+
referenceType: ReferenceType.References,
|
|
49
|
+
referenceOrder: index,
|
|
50
|
+
})),
|
|
51
|
+
];
|
|
@@ -10,6 +10,14 @@ declare module "mailparser" {
|
|
|
10
10
|
interface Attachment {
|
|
11
11
|
partId?: string;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Not a mailparser option: mailparser hands its options to its MIME
|
|
15
|
+
* splitter, mailsplit, whose `ignoreEmbedded` keeps a message/rfc822 part
|
|
16
|
+
* one leaf.
|
|
17
|
+
*/
|
|
18
|
+
interface MailParserOptions {
|
|
19
|
+
ignoreEmbedded?: boolean;
|
|
20
|
+
}
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
export {};
|