@remit/mailbox-service 0.0.118 → 0.0.119
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/body-sync.d.ts +12 -18
- package/dist/body-sync.js +154 -120
- package/dist/filters/pipeline.d.ts +8 -10
- package/dist/filters/pipeline.js +33 -49
- package/dist/imapflow-connection.d.ts +1 -14
- package/dist/imapflow-connection.js +1 -14
- package/dist/index.d.ts +1 -1
- package/dist/test-helpers/fake-imap.d.ts +1 -2
- package/dist/test-helpers/fake-imap.js +1 -2
- package/dist/types.d.ts +3 -5
- package/package.json +2 -1
package/dist/body-sync.d.ts
CHANGED
|
@@ -32,6 +32,11 @@ export interface BodySyncLogger {
|
|
|
32
32
|
warn?(obj: Record<string, unknown>, msg: string): void;
|
|
33
33
|
error?(obj: Record<string, unknown>, msg: string): void;
|
|
34
34
|
}
|
|
35
|
+
export interface SyncBodiesOptions {
|
|
36
|
+
force: boolean;
|
|
37
|
+
concurrency: number;
|
|
38
|
+
bytesInFlight: number;
|
|
39
|
+
}
|
|
35
40
|
export interface SyncBodiesResult {
|
|
36
41
|
syncedCount: number;
|
|
37
42
|
syncedMessageIds: string[];
|
|
@@ -127,7 +132,7 @@ export declare class BodySyncService {
|
|
|
127
132
|
private log;
|
|
128
133
|
private readonly filterPipeline?;
|
|
129
134
|
constructor(messageService: IMessageRepository, storageService: StorageService, threadMessageService: IThreadMessageRepository, addressService: IAddressRepository, envelopeService: IEnvelopeRepository, syncWarningService: SyncWarningService, logger?: BodySyncLogger, placementConfig?: PlacementConfig | undefined, filterConfig?: FilterConfig | undefined, quarantineConfig?: QuarantineConfig | undefined, unsubscribeConfig?: UnsubscribeConfig | undefined, calendarConfig?: CalendarSuggestionConfig | undefined);
|
|
130
|
-
syncBodies(messageIds: string[], accountId: string, accountConfigId: string, folder: BodySyncRoundFolder, getConnection: ConnectionGetter, force
|
|
135
|
+
syncBodies(messageIds: string[], accountId: string, accountConfigId: string, folder: BodySyncRoundFolder, getConnection: ConnectionGetter, { force, concurrency, bytesInFlight }: SyncBodiesOptions): Promise<SyncBodiesResult>;
|
|
131
136
|
/**
|
|
132
137
|
* Set a message aside because its body will not parse (issue #72). The
|
|
133
138
|
* fingerprint comes from the BodyPart rows metadata sync already wrote: the
|
|
@@ -135,22 +140,10 @@ export declare class BodySyncService {
|
|
|
135
140
|
*/
|
|
136
141
|
private recordQuarantine;
|
|
137
142
|
private buildResult;
|
|
138
|
-
|
|
139
|
-
* Stream one message body straight to storage while teeing the bytes into a
|
|
140
|
-
* buffer for the parse-dependent steps (snippet, classification, parsed-body
|
|
141
|
-
* cache, per-part objects). The S3 upload never sees a whole-body concat —
|
|
142
|
-
* the storage service streams it — but mailparser still needs the full bytes,
|
|
143
|
-
* so we collect them in parallel. Later issues move parsing off the hot path.
|
|
144
|
-
*
|
|
145
|
-
* `isReStore` marks a re-fetch of a message whose row already carried
|
|
146
|
-
* `bodyStorageKey` (the `force` re-arm in {@link syncBodies}): the
|
|
147
|
-
* post-store steps then re-materialize the body artifacts without
|
|
148
|
-
* re-running any decision (issue #1011).
|
|
149
|
-
*/
|
|
150
|
-
private storeStreamedBody;
|
|
143
|
+
private storeFetchedBody;
|
|
151
144
|
/**
|
|
152
145
|
* The steps every synced body must go through regardless of which path
|
|
153
|
-
* fetched it first — the sync path ({@link
|
|
146
|
+
* fetched it first — the sync path ({@link storeFetchedBody}) and the
|
|
154
147
|
* read-path backfill ({@link fetchAndGetBody}) both call this instead of
|
|
155
148
|
* duplicating (and drifting on) their own subset. Before issue #1271,
|
|
156
149
|
* `fetchAndGetBody` skipped classification/placement entirely — a message
|
|
@@ -257,11 +250,12 @@ export declare class BodySyncService {
|
|
|
257
250
|
* move); the caller applies them. A no-op returning no actions when no
|
|
258
251
|
* {@link FilterConfig} is wired.
|
|
259
252
|
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
253
|
+
* A failed filter read never fails the body store (the #1246 placement
|
|
254
|
+
* precedent). The applies of the returned decision are the caller's and are
|
|
255
|
+
* deliberately unswallowed.
|
|
263
256
|
*/
|
|
264
257
|
private evaluateFilters;
|
|
258
|
+
private batchLookups;
|
|
265
259
|
/**
|
|
266
260
|
* Resolve the placement decision for a message, BEFORE the single Message
|
|
267
261
|
* update — so both the `movedByRemit` flag and the audit verdict join that
|
package/dist/body-sync.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { PassThrough } from "node:stream";
|
|
2
1
|
import { inspect } from "node:util";
|
|
3
2
|
import { provisionDefaultCalendar, recordCalendarSuggestion, } from "@remit/calendar-service";
|
|
4
3
|
import { isSenderMuted, } from "@remit/data-ports";
|
|
@@ -8,6 +7,7 @@ import { isEncapsulatedMessage } from "@remit/data-ports/message-parts";
|
|
|
8
7
|
import { isBulkSender } from "@remit/data-ports/wellknown";
|
|
9
8
|
import { FilterState, MessageClassificationState, PlacementAction, PlacementConfidence, QuarantineFailureStage, SenderTrust, SyncWarningStage, } from "@remit/domain-enums";
|
|
10
9
|
import { isStorageNotFoundError, } from "@remit/storage-service";
|
|
10
|
+
import { Semaphore } from "async-mutex";
|
|
11
11
|
import { simpleParser } from "mailparser";
|
|
12
12
|
import pMap from "p-map";
|
|
13
13
|
import { BodyParseError, parseMessageBody } from "./body-parse.js";
|
|
@@ -16,7 +16,7 @@ import { calendarParts } from "./calendar-parts.js";
|
|
|
16
16
|
import { classifyParsedMessage, extractPrimaryFromEmail, } from "./classify-message.js";
|
|
17
17
|
import { extractListId } from "./filters/list-id.js";
|
|
18
18
|
import { FilterPipeline, } from "./filters/pipeline.js";
|
|
19
|
-
import { placementOf, resolveFolderRoleMailboxes } from "./folder-placement.js";
|
|
19
|
+
import { placementOf, resolveFolderRoleMailboxes, } from "./folder-placement.js";
|
|
20
20
|
import { classifyPlacement, resolveSenderPlacement, } from "./heuristics/classifyPlacement.js";
|
|
21
21
|
import { denormalizeMessageCategory, hasDecidedCategory, } from "./message-category.js";
|
|
22
22
|
import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
|
|
@@ -137,6 +137,22 @@ export const toParsedBody = (parsed) => ({
|
|
|
137
137
|
const noopLogger = {
|
|
138
138
|
info: () => { },
|
|
139
139
|
};
|
|
140
|
+
// A failed load is forgotten, so the next message retries it rather than the
|
|
141
|
+
// rest of the batch inheriting the failure.
|
|
142
|
+
const once = (load) => {
|
|
143
|
+
let loaded;
|
|
144
|
+
return () => {
|
|
145
|
+
if (loaded)
|
|
146
|
+
return loaded;
|
|
147
|
+
const attempt = load();
|
|
148
|
+
loaded = attempt;
|
|
149
|
+
attempt.then(undefined, () => {
|
|
150
|
+
if (loaded === attempt)
|
|
151
|
+
loaded = undefined;
|
|
152
|
+
});
|
|
153
|
+
return attempt;
|
|
154
|
+
};
|
|
155
|
+
};
|
|
140
156
|
export class BodySyncService {
|
|
141
157
|
messageService;
|
|
142
158
|
storageService;
|
|
@@ -168,9 +184,7 @@ export class BodySyncService {
|
|
|
168
184
|
? new FilterPipeline(filterConfig, this.log)
|
|
169
185
|
: undefined;
|
|
170
186
|
}
|
|
171
|
-
async syncBodies(messageIds, accountId, accountConfigId, folder, getConnection, force
|
|
172
|
-
const syncedMessageIds = [];
|
|
173
|
-
const syncedUids = [];
|
|
187
|
+
async syncBodies(messageIds, accountId, accountConfigId, folder, getConnection, { force, concurrency, bytesInFlight }) {
|
|
174
188
|
let skippedCount = 0;
|
|
175
189
|
const location = {
|
|
176
190
|
accountId,
|
|
@@ -234,63 +248,81 @@ export class BodySyncService {
|
|
|
234
248
|
pending.set(message.uid, messageId);
|
|
235
249
|
}
|
|
236
250
|
if (pending.size === 0) {
|
|
237
|
-
return this.buildResult(
|
|
251
|
+
return this.buildResult([], skippedCount, []);
|
|
238
252
|
}
|
|
239
253
|
const connection = await getConnection();
|
|
240
254
|
// Single SELECT for the whole batch. openBox is idempotent, so a warm
|
|
241
255
|
// connection already on this mailbox skips the SELECT entirely.
|
|
242
256
|
await connection.openBox(folder.fullPath);
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
257
|
+
const lookups = this.batchLookups(accountConfigId, accountId);
|
|
258
|
+
const log = this.log;
|
|
259
|
+
const stream = { lost: false, fault: null, failed: false };
|
|
260
|
+
const budget = new Semaphore(bytesInFlight);
|
|
261
|
+
const endStream = (error) => {
|
|
262
|
+
log.error?.({ error: error.message }, "Body fetch stream failed");
|
|
263
|
+
// A dropped connection leaves every not-yet-yielded UID in `pending`;
|
|
264
|
+
// they fall through to failedMessageIds and re-enqueue. Any other error
|
|
265
|
+
// is a real fault, raised once the stores already running have finished.
|
|
266
|
+
if (isConnectionDrop(error)) {
|
|
267
|
+
stream.lost = true;
|
|
268
|
+
log.info?.({ remainingCount: pending.size }, "Connection lost, aborting batch");
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
stream.fault = { error };
|
|
272
|
+
return null;
|
|
273
|
+
};
|
|
274
|
+
// The FETCH stays one command on the one connection, read in order; no
|
|
275
|
+
// row is pulled after a store has failed.
|
|
276
|
+
async function* fetchedBodies() {
|
|
277
|
+
const rows = connection.fetchMessageBodies([...pending.keys()]);
|
|
278
|
+
for (;;) {
|
|
279
|
+
const step = await rows.next().catch(endStream);
|
|
280
|
+
if (!step || step.done)
|
|
281
|
+
return;
|
|
282
|
+
const { uid, source } = step.value;
|
|
248
283
|
const messageId = pending.get(uid);
|
|
249
|
-
if (!messageId)
|
|
250
|
-
// A UID we didn't ask for — drain the stream so the connection
|
|
251
|
-
// stays usable, then ignore it.
|
|
252
|
-
source.resume();
|
|
284
|
+
if (!messageId)
|
|
253
285
|
continue;
|
|
286
|
+
if (stream.failed) {
|
|
287
|
+
await rows.return(undefined);
|
|
288
|
+
return;
|
|
254
289
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
// Anything else is our own storage, database or queue failing. It
|
|
272
|
-
// aborts the batch, which redelivers whole; what stored skips.
|
|
273
|
-
throw error;
|
|
274
|
-
}
|
|
290
|
+
yield { uid, messageId, body: source };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const stored = await pMap(fetchedBodies(), async ({ uid, messageId, body }) => {
|
|
294
|
+
// A body larger than the whole budget takes all of it and goes alone.
|
|
295
|
+
const outcome = await budget.runExclusive(() => this.storeFetchedBody(messageId, accountId, accountConfigId, body, folder.placement, reStoredMessageIds.has(messageId), lookups, location), Math.min(body.length, bytesInFlight));
|
|
296
|
+
if (outcome.kind === "Failed")
|
|
297
|
+
stream.failed = true;
|
|
298
|
+
return { uid, messageId, outcome };
|
|
299
|
+
}, { concurrency });
|
|
300
|
+
const syncedMessageIds = [];
|
|
301
|
+
const syncedUids = [];
|
|
302
|
+
const faults = [];
|
|
303
|
+
for (const { uid, messageId, outcome } of stored) {
|
|
304
|
+
if (outcome.kind === "Stored") {
|
|
275
305
|
pending.delete(uid);
|
|
276
306
|
syncedMessageIds.push(messageId);
|
|
277
307
|
syncedUids.push(uid);
|
|
278
308
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
// Fail-fast: a dropped connection mid-stream leaves every not-yet-yielded
|
|
283
|
-
// UID in `pending`; they fall through to failedMessageIds and re-enqueue.
|
|
284
|
-
// Any other error is a real fault — let it crash.
|
|
285
|
-
if (!isConnectionDrop(error)) {
|
|
286
|
-
throw error;
|
|
309
|
+
if (outcome.kind === "Quarantined") {
|
|
310
|
+
pending.delete(uid);
|
|
311
|
+
skippedCount++;
|
|
287
312
|
}
|
|
288
|
-
|
|
289
|
-
|
|
313
|
+
if (outcome.kind === "Failed")
|
|
314
|
+
faults.push(outcome.error);
|
|
290
315
|
}
|
|
316
|
+
// A store failing on our own storage, database or queue fails the batch,
|
|
317
|
+
// which redelivers whole; what stored skips on the redelivery.
|
|
318
|
+
if (faults.length > 0)
|
|
319
|
+
throw faults[0];
|
|
320
|
+
if (stream.fault)
|
|
321
|
+
throw stream.fault.error;
|
|
291
322
|
// Anything still pending was never yielded (mid-stream drop or a UID the
|
|
292
323
|
// server silently omitted) — re-enqueue it.
|
|
293
324
|
const failedMessageIds = [...pending.values()];
|
|
325
|
+
const connectionLost = stream.lost;
|
|
294
326
|
await this.syncWarningService.settle({
|
|
295
327
|
accountConfigId,
|
|
296
328
|
accountId,
|
|
@@ -341,56 +373,36 @@ export class BodySyncService {
|
|
|
341
373
|
failedMessageIds,
|
|
342
374
|
};
|
|
343
375
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
resolve();
|
|
371
|
-
});
|
|
372
|
-
source.on("error", (err) => {
|
|
373
|
-
toStorage.destroy(err);
|
|
374
|
-
reject(err);
|
|
375
|
-
});
|
|
376
|
-
});
|
|
377
|
-
const [ref] = await Promise.all([
|
|
378
|
-
this.storageService.storeMessageBodyStream({
|
|
379
|
-
accountConfigId,
|
|
380
|
-
accountId,
|
|
381
|
-
messageId,
|
|
382
|
-
content: toStorage,
|
|
383
|
-
}),
|
|
384
|
-
tee,
|
|
385
|
-
]);
|
|
386
|
-
const body = Buffer.concat(chunks);
|
|
387
|
-
await this.applyPostStoreSteps(messageId, accountId, accountConfigId, body, {
|
|
388
|
-
uri: ref.uri,
|
|
389
|
-
}, placement, isReStore);
|
|
376
|
+
async storeFetchedBody(messageId, accountId, accountConfigId, body, placement, isReStore, lookups, location) {
|
|
377
|
+
const stored = await this.storageService
|
|
378
|
+
.storeMessageBody({
|
|
379
|
+
accountConfigId,
|
|
380
|
+
accountId,
|
|
381
|
+
messageId,
|
|
382
|
+
content: body,
|
|
383
|
+
})
|
|
384
|
+
.then((ref) => this.applyPostStoreSteps(messageId, accountId, accountConfigId, body, { uri: ref.uri }, placement, isReStore, lookups))
|
|
385
|
+
.then(() => ({ kind: "Stored" }), (error) => ({ kind: "Failed", error }));
|
|
386
|
+
if (stored.kind !== "Failed")
|
|
387
|
+
return stored;
|
|
388
|
+
const { error } = stored;
|
|
389
|
+
// The one error in this frame that is the message's own fault. It
|
|
390
|
+
// reaches here from the parse call and nowhere else, so the S3, DB and
|
|
391
|
+
// queue work this same frame wraps cannot be mistaken for it. The row is
|
|
392
|
+
// durable before the UID leaves `pending`, so the message is never let go
|
|
393
|
+
// of unrecorded.
|
|
394
|
+
if (!(error instanceof BodyParseError) || !this.quarantineConfig) {
|
|
395
|
+
this.log.error?.({ messageId, accountId, error: inspect(error) }, "Body store failed");
|
|
396
|
+
return stored;
|
|
397
|
+
}
|
|
398
|
+
return this.recordQuarantine(this.quarantineConfig, messageId, location, error).then(() => ({ kind: "Quarantined" }), (recordError) => ({
|
|
399
|
+
kind: "Failed",
|
|
400
|
+
error: recordError,
|
|
401
|
+
}));
|
|
390
402
|
}
|
|
391
403
|
/**
|
|
392
404
|
* The steps every synced body must go through regardless of which path
|
|
393
|
-
* fetched it first — the sync path ({@link
|
|
405
|
+
* fetched it first — the sync path ({@link storeFetchedBody}) and the
|
|
394
406
|
* read-path backfill ({@link fetchAndGetBody}) both call this instead of
|
|
395
407
|
* duplicating (and drifting on) their own subset. Before issue #1271,
|
|
396
408
|
* `fetchAndGetBody` skipped classification/placement entirely — a message
|
|
@@ -420,7 +432,7 @@ export class BodySyncService {
|
|
|
420
432
|
* placement/filter/category machinery is skipped entirely and the
|
|
421
433
|
* message's existing category is carried forward unchanged.
|
|
422
434
|
*/
|
|
423
|
-
isReStore) {
|
|
435
|
+
isReStore, lookups) {
|
|
424
436
|
// The one operation on this path that can fail because of how the message
|
|
425
437
|
// is built. Its own try block lives in `parseMessageBody`, so this frame —
|
|
426
438
|
// which also wraps the S3 body write, the parsed-body cache, the placement
|
|
@@ -465,12 +477,8 @@ export class BodySyncService {
|
|
|
465
477
|
// UpdateItem. The verdict is recorded whenever Remit decided to act
|
|
466
478
|
// (action != leave); the move is enqueued only for a confident,
|
|
467
479
|
// actionable verdict.
|
|
468
|
-
const resolved = await this.resolvePlacement(messageId, accountId, accountConfigId, parsed, classification);
|
|
469
|
-
|
|
470
|
-
// FilterPipeline.evaluate swallows any read/decision failure and returns
|
|
471
|
-
// no actions, so a schema-drifted filter query never fails the body store.
|
|
472
|
-
// The DECISION is computed here; the applies below are unswallowed.
|
|
473
|
-
const filterDecision = await this.evaluateFilters(accountConfigId, accountId, messageId, parsed);
|
|
480
|
+
const resolved = await this.resolvePlacement(lookups, messageId, accountId, accountConfigId, parsed, classification);
|
|
481
|
+
const filterDecision = await this.evaluateFilters(lookups, messageId, parsed);
|
|
474
482
|
// Store the parsed-body cache BEFORE persisting bodyStorageKey. The skip
|
|
475
483
|
// guard in syncBodies treats a stored bodyStorageKey as "fully synced",
|
|
476
484
|
// so if we wrote it first and the parsed-cache write then failed, the
|
|
@@ -549,7 +557,7 @@ export class BodySyncService {
|
|
|
549
557
|
// the message is already \Seen), so a failure here safely re-fires on the
|
|
550
558
|
// next attempt rather than being lost behind the bodyStorageKey skip guard.
|
|
551
559
|
await this.applyUnsubscribedAutoRead(messageId, accountId, accountConfigId, parsed, existingMessage.bodyStorageKey);
|
|
552
|
-
await this.deriveCalendarSuggestions(messageId, accountConfigId, parsed, existingMessage.bodyStorageKey);
|
|
560
|
+
await this.deriveCalendarSuggestions(lookups, messageId, accountConfigId, parsed, existingMessage.bodyStorageKey);
|
|
553
561
|
const moved = Boolean(resolved.move || filterMoved);
|
|
554
562
|
// ONE Message UpdateItem per synced message: bodyStorageKey + every
|
|
555
563
|
// classification/derived field + the move flag + the audit verdict,
|
|
@@ -657,13 +665,11 @@ export class BodySyncService {
|
|
|
657
665
|
messageId,
|
|
658
666
|
content: body,
|
|
659
667
|
});
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
|
|
665
|
-
// a genuine first store and takes the full decision pass.
|
|
666
|
-
parsed = await this.applyPostStoreSteps(messageId, accountId, accountConfigId, body, { uri: ref.uri }, folder.placement, message.bodyStorageKey != null);
|
|
668
|
+
// A store from here is a re-store exactly when the row already claimed
|
|
669
|
+
// a stored body — the NoSuchKey fallback above; a row with no
|
|
670
|
+
// bodyStorageKey is a genuine first store and takes the full decision
|
|
671
|
+
// pass.
|
|
672
|
+
parsed = await this.applyPostStoreSteps(messageId, accountId, accountConfigId, body, { uri: ref.uri }, folder.placement, message.bodyStorageKey != null, this.batchLookups(accountConfigId, accountId));
|
|
667
673
|
}
|
|
668
674
|
else {
|
|
669
675
|
parsed = await simpleParser(body);
|
|
@@ -839,7 +845,7 @@ export class BodySyncService {
|
|
|
839
845
|
* repository failure here is logged loudly with the messageId rather than
|
|
840
846
|
* failing a body store that otherwise succeeded.
|
|
841
847
|
*/
|
|
842
|
-
async deriveCalendarSuggestions(messageId, accountConfigId, parsed, storedBodyKey) {
|
|
848
|
+
async deriveCalendarSuggestions(lookups, messageId, accountConfigId, parsed, storedBodyKey) {
|
|
843
849
|
if (!this.calendarConfig)
|
|
844
850
|
return;
|
|
845
851
|
if (hasStoredBody(storedBodyKey))
|
|
@@ -847,7 +853,7 @@ export class BodySyncService {
|
|
|
847
853
|
const parts = calendarParts(parsed);
|
|
848
854
|
if (parts.length === 0)
|
|
849
855
|
return;
|
|
850
|
-
const { calendarStore, calendarSuggestionService
|
|
856
|
+
const { calendarStore, calendarSuggestionService } = this.calendarConfig;
|
|
851
857
|
await Promise.resolve()
|
|
852
858
|
.then(async () => {
|
|
853
859
|
// `dismiss{muteSender:true}` wrote a standing rule naming this
|
|
@@ -856,7 +862,7 @@ export class BodySyncService {
|
|
|
856
862
|
// move, so this is the only reader such a rule has.
|
|
857
863
|
const sender = extractPrimaryFromEmail(parsed);
|
|
858
864
|
if (sender) {
|
|
859
|
-
const active = await
|
|
865
|
+
const active = await lookups.activeFilters();
|
|
860
866
|
if (isSenderMuted(active, sender)) {
|
|
861
867
|
this.log.debug?.({ messageId, sender }, "Sender is muted; offering no calendar suggestion");
|
|
862
868
|
return;
|
|
@@ -914,14 +920,42 @@ export class BodySyncService {
|
|
|
914
920
|
* move); the caller applies them. A no-op returning no actions when no
|
|
915
921
|
* {@link FilterConfig} is wired.
|
|
916
922
|
*
|
|
917
|
-
*
|
|
918
|
-
*
|
|
919
|
-
*
|
|
923
|
+
* A failed filter read never fails the body store (the #1246 placement
|
|
924
|
+
* precedent). The applies of the returned decision are the caller's and are
|
|
925
|
+
* deliberately unswallowed.
|
|
920
926
|
*/
|
|
921
|
-
async evaluateFilters(
|
|
922
|
-
|
|
927
|
+
async evaluateFilters(lookups, messageId, parsed) {
|
|
928
|
+
const { filterPipeline } = this;
|
|
929
|
+
if (!filterPipeline)
|
|
923
930
|
return { labels: [] };
|
|
924
|
-
return
|
|
931
|
+
return lookups
|
|
932
|
+
.usableFilters()
|
|
933
|
+
.then((usable) => filterPipeline.decide(usable, toFilterMessage(parsed)))
|
|
934
|
+
.catch((error) => {
|
|
935
|
+
this.log.error?.({
|
|
936
|
+
alert: "body_sync_filter_match_failed",
|
|
937
|
+
messageId,
|
|
938
|
+
errorName: error?.name,
|
|
939
|
+
error: inspect(error),
|
|
940
|
+
}, "Filter matching failed; body already stored, continuing without filter actions (best-effort, non-fatal)");
|
|
941
|
+
return { labels: [] };
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
batchLookups(accountConfigId, accountId) {
|
|
945
|
+
const { placementConfig, filterPipeline } = this;
|
|
946
|
+
const filterService = this.filterConfig?.filterService ?? this.calendarConfig?.filterService;
|
|
947
|
+
const activeFilters = once(async () => filterService
|
|
948
|
+
? filterService.listByAccountAndState(accountConfigId, FilterState.Active)
|
|
949
|
+
: []);
|
|
950
|
+
return {
|
|
951
|
+
roleMailboxes: once(async () => placementConfig
|
|
952
|
+
? resolveFolderRoleMailboxes(placementConfig.mailboxSpecialUseService, accountId)
|
|
953
|
+
: { junkMailbox: null, inboxMailbox: null }),
|
|
954
|
+
activeFilters,
|
|
955
|
+
usableFilters: once(async () => filterPipeline
|
|
956
|
+
? filterPipeline.prepare(accountConfigId, accountId, await activeFilters())
|
|
957
|
+
: []),
|
|
958
|
+
};
|
|
925
959
|
}
|
|
926
960
|
/**
|
|
927
961
|
* Resolve the placement decision for a message, BEFORE the single Message
|
|
@@ -950,10 +984,10 @@ export class BodySyncService {
|
|
|
950
984
|
* to leave the message alone or placement itself failed; the alert log is
|
|
951
985
|
* what distinguishes the latter, and neither case marks the message decided.
|
|
952
986
|
*/
|
|
953
|
-
async resolvePlacement(messageId, accountId, accountConfigId, parsed, classification) {
|
|
987
|
+
async resolvePlacement(lookups, messageId, accountId, accountConfigId, parsed, classification) {
|
|
954
988
|
if (!this.placementConfig)
|
|
955
989
|
return {};
|
|
956
|
-
return this.computePlacement(this.placementConfig, messageId, accountId, accountConfigId, parsed, classification).catch((error) => {
|
|
990
|
+
return this.computePlacement(this.placementConfig, lookups, messageId, accountId, accountConfigId, parsed, classification).catch((error) => {
|
|
957
991
|
this.log.error?.({
|
|
958
992
|
alert: "body_sync_placement_failed",
|
|
959
993
|
messageId,
|
|
@@ -965,7 +999,7 @@ export class BodySyncService {
|
|
|
965
999
|
return {};
|
|
966
1000
|
});
|
|
967
1001
|
}
|
|
968
|
-
async computePlacement(placementConfig, messageId, accountId, accountConfigId, parsed, classification) {
|
|
1002
|
+
async computePlacement(placementConfig, lookups, messageId, accountId, accountConfigId, parsed, classification) {
|
|
969
1003
|
const { mailboxSpecialUseService } = placementConfig;
|
|
970
1004
|
const message = await this.messageService.get(messageId);
|
|
971
1005
|
// Issue #383 / #1011: placement still runs once per message (RFC 039
|
|
@@ -974,7 +1008,7 @@ export class BodySyncService {
|
|
|
974
1008
|
// (`fetchAndGetBody`'s `NoSuchKey` fallback, `syncBodies(..., force:
|
|
975
1009
|
// true)`) skip the decision steps entirely, so only the pass that first
|
|
976
1010
|
// stores a body ever reaches this method.
|
|
977
|
-
const roleMailboxes = await
|
|
1011
|
+
const roleMailboxes = await lookups.roleMailboxes();
|
|
978
1012
|
const { junkMailbox, inboxMailbox } = roleMailboxes;
|
|
979
1013
|
const placement = placementOf(message.mailboxId, roleMailboxes);
|
|
980
1014
|
const fromEmail = extractPrimaryFromEmail(parsed);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IFilterRepository, IMailboxRepository, IMessageLabelRepository } from "@remit/data-ports";
|
|
1
|
+
import type { FilterItem, IFilterRepository, IMailboxRepository, IMessageLabelRepository } from "@remit/data-ports";
|
|
2
2
|
import type { PlacementMoveService } from "../placement-move.js";
|
|
3
3
|
import { type FilterMessage } from "./match.js";
|
|
4
4
|
export interface FilterLogger {
|
|
@@ -34,25 +34,23 @@ export interface FilterDecision {
|
|
|
34
34
|
filterId: string;
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
|
+
export type PreparedFilters = readonly FilterItem[];
|
|
37
38
|
/**
|
|
38
39
|
* Evaluates a synced message against the account's active filters and resolves
|
|
39
40
|
* the actions to apply (RFC 034). Reads only — listing filters and refreshing
|
|
40
41
|
* lazy expiry; the caller applies the
|
|
41
42
|
* resulting {@link FilterDecision}.
|
|
42
43
|
*
|
|
43
|
-
* `
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* writes and the move enqueue — is the caller's responsibility and is *not*
|
|
48
|
-
* swallowed there, so a genuine DDB/SQS write failure still propagates and
|
|
49
|
-
* requeues the message.
|
|
44
|
+
* `prepare` narrows the account's active filters to the ones that can act on
|
|
45
|
+
* one account's mail; `decide` matches a message against them without a read.
|
|
46
|
+
* The apply of the decision — label writes and the move enqueue — is the
|
|
47
|
+
* caller's responsibility.
|
|
50
48
|
*/
|
|
51
49
|
export declare class FilterPipeline {
|
|
52
50
|
private readonly config;
|
|
53
51
|
private readonly log;
|
|
54
52
|
constructor(config: FilterConfig, log: FilterLogger);
|
|
55
|
-
|
|
53
|
+
decide(prepared: PreparedFilters, msg: FilterMessage): FilterDecision;
|
|
56
54
|
/**
|
|
57
55
|
* A filter sits on the account config and sees every account's mail, but its
|
|
58
56
|
* folder is one account's: mail of another account is not this filter's to
|
|
@@ -60,5 +58,5 @@ export declare class FilterPipeline {
|
|
|
60
58
|
* more turns the filter off, with the reason the filter list shows.
|
|
61
59
|
*/
|
|
62
60
|
private appliesTo;
|
|
63
|
-
|
|
61
|
+
prepare(accountConfigId: string, accountId: string, active: readonly FilterItem[]): Promise<PreparedFilters>;
|
|
64
62
|
}
|
package/dist/filters/pipeline.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { inspect } from "node:util";
|
|
2
1
|
import { FilterDisabledReason, FilterState } from "@remit/domain-enums";
|
|
3
2
|
import { literalClausesMatch, NO_ACTION, selectMoveWinner, } from "./match.js";
|
|
4
3
|
const EMPTY_DECISION = { labels: [] };
|
|
@@ -8,13 +7,10 @@ const EMPTY_DECISION = { labels: [] };
|
|
|
8
7
|
* lazy expiry; the caller applies the
|
|
9
8
|
* resulting {@link FilterDecision}.
|
|
10
9
|
*
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* writes and the move enqueue — is the caller's responsibility and is *not*
|
|
16
|
-
* swallowed there, so a genuine DDB/SQS write failure still propagates and
|
|
17
|
-
* requeues the message.
|
|
10
|
+
* `prepare` narrows the account's active filters to the ones that can act on
|
|
11
|
+
* one account's mail; `decide` matches a message against them without a read.
|
|
12
|
+
* The apply of the decision — label writes and the move enqueue — is the
|
|
13
|
+
* caller's responsibility.
|
|
18
14
|
*/
|
|
19
15
|
export class FilterPipeline {
|
|
20
16
|
config;
|
|
@@ -23,17 +19,32 @@ export class FilterPipeline {
|
|
|
23
19
|
this.config = config;
|
|
24
20
|
this.log = log;
|
|
25
21
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
alert: "body_sync_filter_match_failed",
|
|
30
|
-
messageId,
|
|
31
|
-
accountConfigId,
|
|
32
|
-
errorName: error?.name,
|
|
33
|
-
error: inspect(error),
|
|
34
|
-
}, "Filter matching failed; body already stored, continuing without filter actions (best-effort, non-fatal)");
|
|
22
|
+
decide(prepared, msg) {
|
|
23
|
+
const matched = prepared.filter((filter) => filterMatches(filter, msg));
|
|
24
|
+
if (matched.length === 0)
|
|
35
25
|
return EMPTY_DECISION;
|
|
36
|
-
|
|
26
|
+
const labels = [];
|
|
27
|
+
const seenLabels = new Set();
|
|
28
|
+
for (const filter of matched) {
|
|
29
|
+
if (filter.actionLabelId === NO_ACTION)
|
|
30
|
+
continue;
|
|
31
|
+
if (seenLabels.has(filter.actionLabelId))
|
|
32
|
+
continue;
|
|
33
|
+
seenLabels.add(filter.actionLabelId);
|
|
34
|
+
labels.push({ labelId: filter.actionLabelId, filterId: filter.filterId });
|
|
35
|
+
}
|
|
36
|
+
const winner = selectMoveWinner(matched.filter((filter) => filter.actionMailboxId !== NO_ACTION));
|
|
37
|
+
return {
|
|
38
|
+
labels,
|
|
39
|
+
...(winner
|
|
40
|
+
? {
|
|
41
|
+
move: {
|
|
42
|
+
destinationMailboxId: winner.actionMailboxId,
|
|
43
|
+
filterId: winner.filterId,
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
: {}),
|
|
47
|
+
};
|
|
37
48
|
}
|
|
38
49
|
/**
|
|
39
50
|
* A filter sits on the account config and sees every account's mail, but its
|
|
@@ -54,11 +65,8 @@ export class FilterPipeline {
|
|
|
54
65
|
this.log.warn?.({ filterId: filter.filterId, accountConfigId }, "Filter turned off: the folder it moves mail into no longer exists");
|
|
55
66
|
return false;
|
|
56
67
|
}
|
|
57
|
-
async
|
|
58
|
-
const
|
|
59
|
-
if (active.length === 0)
|
|
60
|
-
return EMPTY_DECISION;
|
|
61
|
-
const matched = [];
|
|
68
|
+
async prepare(accountConfigId, accountId, active) {
|
|
69
|
+
const filters = [];
|
|
62
70
|
for (const filter of active) {
|
|
63
71
|
// Lazy expiry (RFC 034 Decision 1.2): reading a Temporary filter past
|
|
64
72
|
// its expiresAt patches it to Expired on this read and drops it from
|
|
@@ -68,33 +76,9 @@ export class FilterPipeline {
|
|
|
68
76
|
continue;
|
|
69
77
|
if (!(await this.appliesTo(accountConfigId, accountId, usable)))
|
|
70
78
|
continue;
|
|
71
|
-
|
|
72
|
-
matched.push(usable);
|
|
79
|
+
filters.push(usable);
|
|
73
80
|
}
|
|
74
|
-
|
|
75
|
-
return EMPTY_DECISION;
|
|
76
|
-
const labels = [];
|
|
77
|
-
const seenLabels = new Set();
|
|
78
|
-
for (const filter of matched) {
|
|
79
|
-
if (filter.actionLabelId === NO_ACTION)
|
|
80
|
-
continue;
|
|
81
|
-
if (seenLabels.has(filter.actionLabelId))
|
|
82
|
-
continue;
|
|
83
|
-
seenLabels.add(filter.actionLabelId);
|
|
84
|
-
labels.push({ labelId: filter.actionLabelId, filterId: filter.filterId });
|
|
85
|
-
}
|
|
86
|
-
const winner = selectMoveWinner(matched.filter((filter) => filter.actionMailboxId !== NO_ACTION));
|
|
87
|
-
return {
|
|
88
|
-
labels,
|
|
89
|
-
...(winner
|
|
90
|
-
? {
|
|
91
|
-
move: {
|
|
92
|
-
destinationMailboxId: winner.actionMailboxId,
|
|
93
|
-
filterId: winner.filterId,
|
|
94
|
-
},
|
|
95
|
-
}
|
|
96
|
-
: {}),
|
|
97
|
-
};
|
|
81
|
+
return filters;
|
|
98
82
|
}
|
|
99
83
|
}
|
|
100
84
|
const filterMatches = (filter, msg) => literalClausesMatch(filter.literalClauses, filter.matchOperator, msg);
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* Modern async/await replacement for the node-imap based ImapConnection.
|
|
5
5
|
* Provides the same interface but uses ImapFlow under the hood.
|
|
6
6
|
*/
|
|
7
|
-
import { Readable } from "node:stream";
|
|
8
7
|
import type { FlatMailboxInfo, ImapBoxStatus, ImapConnectionConfig, ImapConnectionState, ImapEnvelopeSnapshot, ImapMailboxStatus, ImapMessage, ImapNamespaces } from "./types.js";
|
|
9
8
|
/**
|
|
10
9
|
* Convert an envelope date into an ISO string, tolerating non-Date values.
|
|
@@ -196,21 +195,9 @@ export declare class ImapFlowConnection {
|
|
|
196
195
|
* @returns The raw message body, or null when the UID FETCH matched no message
|
|
197
196
|
*/
|
|
198
197
|
fetchMessageBody: (uid: number) => Promise<Buffer | null>;
|
|
199
|
-
/**
|
|
200
|
-
* Fetch full message bodies (RFC822 source) for many UIDs in ONE pipelined
|
|
201
|
-
* ranged UID FETCH on a single connection — the desktop-client pattern.
|
|
202
|
-
*
|
|
203
|
-
* Mirrors `fetchMessages`: one comma-joined UID range, one `client.fetch`,
|
|
204
|
-
* one SELECT (the caller opens the box once for the whole batch). Yields
|
|
205
|
-
* `{ uid, source }` as each message arrives so the caller can stream each
|
|
206
|
-
* body straight to storage without buffering the whole batch.
|
|
207
|
-
*
|
|
208
|
-
* `source` is a readable stream over the message bytes — callers must
|
|
209
|
-
* consume it (e.g. pipe to an upload) before requesting the next item.
|
|
210
|
-
*/
|
|
211
198
|
fetchMessageBodies(uids: number[]): AsyncGenerator<{
|
|
212
199
|
uid: number;
|
|
213
|
-
source:
|
|
200
|
+
source: Buffer;
|
|
214
201
|
}>;
|
|
215
202
|
/**
|
|
216
203
|
* Parse the References header from IMAP headers buffer.
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* Modern async/await replacement for the node-imap based ImapConnection.
|
|
5
5
|
* Provides the same interface but uses ImapFlow under the hood.
|
|
6
6
|
*/
|
|
7
|
-
import { Readable } from "node:stream";
|
|
8
7
|
import { ImapFlow } from "imapflow";
|
|
9
8
|
import { simpleParser } from "mailparser";
|
|
10
9
|
import { MailConnectionError } from "./types.js";
|
|
@@ -613,18 +612,6 @@ export class ImapFlowConnection {
|
|
|
613
612
|
}
|
|
614
613
|
return null;
|
|
615
614
|
};
|
|
616
|
-
/**
|
|
617
|
-
* Fetch full message bodies (RFC822 source) for many UIDs in ONE pipelined
|
|
618
|
-
* ranged UID FETCH on a single connection — the desktop-client pattern.
|
|
619
|
-
*
|
|
620
|
-
* Mirrors `fetchMessages`: one comma-joined UID range, one `client.fetch`,
|
|
621
|
-
* one SELECT (the caller opens the box once for the whole batch). Yields
|
|
622
|
-
* `{ uid, source }` as each message arrives so the caller can stream each
|
|
623
|
-
* body straight to storage without buffering the whole batch.
|
|
624
|
-
*
|
|
625
|
-
* `source` is a readable stream over the message bytes — callers must
|
|
626
|
-
* consume it (e.g. pipe to an upload) before requesting the next item.
|
|
627
|
-
*/
|
|
628
615
|
async *fetchMessageBodies(uids) {
|
|
629
616
|
this.ensureConnected();
|
|
630
617
|
const { client } = this;
|
|
@@ -652,7 +639,7 @@ export class ImapFlowConnection {
|
|
|
652
639
|
if (msg.uid == null || !msg.source) {
|
|
653
640
|
continue;
|
|
654
641
|
}
|
|
655
|
-
yield { uid: msg.uid, source:
|
|
642
|
+
yield { uid: msg.uid, source: msg.source };
|
|
656
643
|
}
|
|
657
644
|
}
|
|
658
645
|
catch (error) {
|
package/dist/index.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export { type ImapTestConfig, testImapConnection, } from "./connection-test.js";
|
|
|
16
16
|
export { type CreatedFilter, createFilter, type FilterPage, type FilterPatch, listFilters, type NewFilter, resolveFilterUpdate, updateFilter, } from "./filter-operations.js";
|
|
17
17
|
export { extractListId, normalizeListId } from "./filters/list-id.js";
|
|
18
18
|
export { clauseMatches, type FilterMessage, type LiteralClauseNarrowing, literalClausesMatch, literalClauseTerms, NO_ACTION, selectMoveWinner, } from "./filters/match.js";
|
|
19
|
-
export { type FilterConfig, type FilterDecision, type FilterLogger, FilterPipeline, } from "./filters/pipeline.js";
|
|
19
|
+
export { type FilterConfig, type FilterDecision, type FilterLogger, FilterPipeline, type PreparedFilters, } from "./filters/pipeline.js";
|
|
20
20
|
export { type FlagPushConfig, type FlagPushEvent, type FlagPushLogger, type FlagPushOperationValue, FlagPushService, } from "./flag-push.js";
|
|
21
21
|
export { type FlagPushTerminalOutcome, type ResolveExhaustedFlagPushDeps, type ResolveExhaustedFlagPushInput, type ResolveExhaustedFlagPushResult, resolveExhaustedFlagPushFailure, } from "./flag-push-terminal.js";
|
|
22
22
|
export { type FlagQueueConfig, type FlagQueueLogger, FlagQueueService, type UpdateFlagsInput, type UpdateFlagsResult, } from "./flag-queue.js";
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { Readable } from "node:stream";
|
|
2
1
|
import type { FlatMailboxInfo, IImapConnection, ImapBodyStructure, ImapBoxStatus, ImapConnectionState, ImapEnvelopeSnapshot, ImapMailboxStatus, ImapMessage, ImapNamespaces } from "../types.js";
|
|
3
2
|
interface StoredMessage {
|
|
4
3
|
raw: Buffer;
|
|
@@ -112,7 +111,7 @@ export declare class FakeImapConnection implements IImapConnection {
|
|
|
112
111
|
fetchMessageBody: (uid: number) => Promise<Buffer | null>;
|
|
113
112
|
fetchMessageBodies(uids: number[]): AsyncGenerator<{
|
|
114
113
|
uid: number;
|
|
115
|
-
source:
|
|
114
|
+
source: Buffer;
|
|
116
115
|
}>;
|
|
117
116
|
private touch;
|
|
118
117
|
addFlags: (uids: number[], flags: string[]) => Promise<void>;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { Readable } from "node:stream";
|
|
2
1
|
import { simpleParser } from "mailparser";
|
|
3
2
|
/** An error shaped the way ImapFlow reports a tagged NO from the server. */
|
|
4
3
|
export const imapError = (code, text) => Object.assign(new Error(`Command failed: [${code}] ${text}`), {
|
|
@@ -393,7 +392,7 @@ export class FakeImapConnection {
|
|
|
393
392
|
for (const uid of uids) {
|
|
394
393
|
const stored = folder.messages.get(uid);
|
|
395
394
|
if (stored)
|
|
396
|
-
yield { uid, source:
|
|
395
|
+
yield { uid, source: stored.raw };
|
|
397
396
|
}
|
|
398
397
|
}
|
|
399
398
|
touch = (detail, uids, change) => {
|
package/dist/types.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Types for the remit-mailbox-service
|
|
3
3
|
*/
|
|
4
|
-
import type { Readable } from "node:stream";
|
|
5
4
|
import type { ImapTransport } from "imapflow";
|
|
6
5
|
export type { ImapTransport };
|
|
7
6
|
/**
|
|
@@ -233,13 +232,12 @@ export interface IImapConnection {
|
|
|
233
232
|
fetchMessageBody(uid: number): Promise<Buffer | null>;
|
|
234
233
|
/**
|
|
235
234
|
* Fetch full message bodies for many UIDs in ONE pipelined ranged UID FETCH.
|
|
236
|
-
* Yields `{ uid, source }` per message
|
|
237
|
-
*
|
|
238
|
-
* be open (one SELECT for the whole batch).
|
|
235
|
+
* Yields `{ uid, source }` per message. Requires a mailbox to be open (one
|
|
236
|
+
* SELECT for the whole batch).
|
|
239
237
|
*/
|
|
240
238
|
fetchMessageBodies(uids: number[]): AsyncGenerator<{
|
|
241
239
|
uid: number;
|
|
242
|
-
source:
|
|
240
|
+
source: Buffer;
|
|
243
241
|
}>;
|
|
244
242
|
/**
|
|
245
243
|
* Add flags to messages by UID.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/mailbox-service",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.119",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"imapflow": "^2.9.1",
|
|
63
63
|
"mailparser": "3.12.2",
|
|
64
64
|
"p-map": "^7.0.4",
|
|
65
|
+
"async-mutex": "0.5.0",
|
|
65
66
|
"tldts": "^7.4.9",
|
|
66
67
|
"@remit/data-ports": "*",
|
|
67
68
|
"@remit/domain-enums": "*",
|