@remit/mailbox-service 0.0.111 → 0.0.113
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account-credentials.d.ts +83 -0
- package/dist/account-credentials.js +104 -0
- package/dist/attribute-mapper.d.ts +36 -0
- package/dist/attribute-mapper.js +119 -0
- package/dist/body-parse.d.ts +28 -0
- package/{src/body-parse.ts → dist/body-parse.js} +18 -26
- package/dist/body-part-mapper.d.ts +92 -0
- package/dist/body-part-mapper.js +301 -0
- package/dist/body-sync-queue.d.ts +59 -0
- package/dist/body-sync-queue.js +56 -0
- package/dist/body-sync-terminal.d.ts +58 -0
- package/dist/body-sync-terminal.js +95 -0
- package/dist/body-sync.d.ts +351 -0
- package/dist/body-sync.js +1204 -0
- package/dist/calendar-parts.d.ts +23 -0
- package/dist/calendar-parts.js +18 -0
- package/dist/classification-backfill.d.ts +91 -0
- package/dist/classification-backfill.js +180 -0
- package/dist/classify-message.d.ts +31 -0
- package/dist/classify-message.js +89 -0
- package/dist/connection-factory.d.ts +54 -0
- package/dist/connection-factory.js +54 -0
- package/dist/connection-test.d.ts +41 -0
- package/dist/connection-test.js +152 -0
- package/dist/external-move.d.ts +94 -0
- package/dist/external-move.js +173 -0
- package/dist/filter-operations.d.ts +42 -0
- package/dist/filter-operations.js +211 -0
- package/dist/filters/filter-config.d.ts +14 -0
- package/dist/filters/filter-config.js +10 -0
- package/{src/filters/list-id.ts → dist/filters/list-id.d.ts} +2 -16
- package/dist/filters/list-id.js +26 -0
- package/dist/filters/match.d.ts +69 -0
- package/dist/filters/match.js +165 -0
- package/dist/filters/pipeline.d.ts +64 -0
- package/dist/filters/pipeline.js +100 -0
- package/{src/flag-push-terminal.ts → dist/flag-push-terminal.d.ts} +19 -77
- package/dist/flag-push-terminal.js +63 -0
- package/dist/flag-push.d.ts +69 -0
- package/dist/flag-push.js +111 -0
- package/dist/flag-queue.d.ts +175 -0
- package/dist/flag-queue.js +291 -0
- package/dist/folder-operations.d.ts +39 -0
- package/dist/folder-operations.js +112 -0
- package/dist/folder-placement.d.ts +13 -0
- package/dist/folder-placement.js +23 -0
- package/dist/heuristics/authenticationResults.d.ts +10 -0
- package/dist/heuristics/authenticationResults.js +187 -0
- package/dist/heuristics/classifyByHeaders.d.ts +82 -0
- package/dist/heuristics/classifyByHeaders.js +365 -0
- package/dist/heuristics/classifyPlacement.d.ts +67 -0
- package/dist/heuristics/classifyPlacement.js +153 -0
- package/dist/heuristics/machineSenders.d.ts +21 -0
- package/{src/heuristics/machineSenders.ts → dist/heuristics/machineSenders.js} +34 -40
- package/dist/heuristics/senderMismatch.d.ts +81 -0
- package/dist/heuristics/senderMismatch.js +192 -0
- package/dist/heuristics/socialDomains.d.ts +14 -0
- package/{src/heuristics/socialDomains.ts → dist/heuristics/socialDomains.js} +9 -9
- package/dist/heuristics/transactionalDomains.d.ts +18 -0
- package/{src/heuristics/transactionalDomains.ts → dist/heuristics/transactionalDomains.js} +8 -8
- package/dist/imapflow-connection.d.ts +377 -0
- package/dist/imapflow-connection.js +1147 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +65 -0
- package/dist/list-id-backfill.d.ts +83 -0
- package/dist/list-id-backfill.js +132 -0
- package/dist/mailbox-cursor-rebuild.d.ts +74 -0
- package/dist/mailbox-cursor-rebuild.js +71 -0
- package/{src/mailbox-cursor.ts → dist/mailbox-cursor.d.ts} +20 -98
- package/dist/mailbox-cursor.js +102 -0
- package/dist/mailbox-intent.d.ts +41 -0
- package/{src/mailbox-intent.ts → dist/mailbox-intent.js} +39 -63
- package/dist/mailbox-management.d.ts +240 -0
- package/dist/mailbox-management.js +576 -0
- package/dist/mailbox-presence.d.ts +52 -0
- package/{src/mailbox-presence.ts → dist/mailbox-presence.js} +19 -30
- package/dist/mailbox-queue.d.ts +126 -0
- package/dist/mailbox-queue.js +214 -0
- package/dist/mailbox-sync.d.ts +93 -0
- package/dist/mailbox-sync.js +490 -0
- package/dist/mailbox-upstream.d.ts +38 -0
- package/{src/mailbox-upstream.ts → dist/mailbox-upstream.js} +37 -47
- package/dist/message-category.d.ts +51 -0
- package/{src/message-category.ts → dist/message-category.js} +28 -71
- package/dist/message-move.d.ts +285 -0
- package/dist/message-move.js +858 -0
- package/dist/message-parser.d.ts +14 -0
- package/dist/message-parser.js +17 -0
- package/{src/message-presence.ts → dist/message-presence.d.ts} +1 -13
- package/dist/message-presence.js +29 -0
- package/dist/message-read.d.ts +51 -0
- package/dist/message-read.js +31 -0
- package/dist/message-sync.d.ts +345 -0
- package/dist/message-sync.js +1358 -0
- package/dist/mime-walker.d.ts +102 -0
- package/dist/mime-walker.js +198 -0
- package/dist/organize-job.d.ts +20 -0
- package/dist/organize-job.js +37 -0
- package/dist/outbox-attachment-content.d.ts +37 -0
- package/dist/outbox-attachment-content.js +50 -0
- package/dist/outbox-attachment-filename.d.ts +19 -0
- package/dist/outbox-attachment-filename.js +64 -0
- package/dist/outbox-attachment.d.ts +148 -0
- package/dist/outbox-attachment.js +273 -0
- package/dist/outbox-queue.d.ts +73 -0
- package/dist/outbox-queue.js +284 -0
- package/{src/outbox-status.ts → dist/outbox-status.d.ts} +1 -6
- package/dist/outbox-status.js +18 -0
- package/dist/ownership.d.ts +24 -0
- package/dist/ownership.js +83 -0
- package/dist/placement-move-terminal.d.ts +67 -0
- package/dist/placement-move-terminal.js +88 -0
- package/dist/placement-move.d.ts +83 -0
- package/dist/placement-move.js +216 -0
- package/{src/placement-settled.ts → dist/placement-settled.d.ts} +19 -50
- package/dist/placement-settled.js +75 -0
- package/dist/quarantine.d.ts +95 -0
- package/dist/quarantine.js +131 -0
- package/dist/refusal.d.ts +31 -0
- package/dist/refusal.js +3 -0
- package/{src/restore-source-placement.ts → dist/restore-source-placement.d.ts} +29 -101
- package/dist/restore-source-placement.js +79 -0
- package/dist/search-text-backfill.d.ts +15 -0
- package/dist/search-text-backfill.js +33 -0
- package/dist/snippet.d.ts +61 -0
- package/dist/snippet.js +129 -0
- package/dist/spam-report.d.ts +86 -0
- package/dist/spam-report.js +241 -0
- package/dist/sync-warning.d.ts +21 -0
- package/dist/sync-warning.js +26 -0
- package/dist/sync-watermarks.d.ts +138 -0
- package/dist/sync-watermarks.js +205 -0
- package/dist/test-helpers/fake-imap.d.ts +151 -0
- package/dist/test-helpers/fake-imap.js +508 -0
- package/dist/test-helpers/folder-roles.d.ts +34 -0
- package/dist/test-helpers/folder-roles.js +47 -0
- package/dist/test-helpers/mime-tree-generator.d.ts +82 -0
- package/dist/test-helpers/mime-tree-generator.js +329 -0
- package/dist/test-helpers/test-store.d.ts +69 -0
- package/dist/test-helpers/test-store.js +193 -0
- package/dist/text/normalizer.d.ts +20 -0
- package/dist/text/normalizer.js +102 -0
- package/dist/thread-message-rows.d.ts +69 -0
- package/dist/thread-message-rows.js +40 -0
- package/dist/thread-root.d.ts +7 -0
- package/dist/thread-root.js +25 -0
- package/dist/types.d.ts +343 -0
- package/dist/types.js +19 -0
- package/package.json +86 -73
- package/src/account-credentials.ts +0 -210
- package/src/adversarial-mime.e2e.test.ts +0 -307
- package/src/attribute-mapper.test.ts +0 -104
- package/src/attribute-mapper.ts +0 -148
- package/src/body-parse.test.ts +0 -55
- package/src/body-part-mapper.property.test.ts +0 -354
- package/src/body-part-mapper.ts +0 -360
- package/src/body-sync-auto-read-writeonce.test.ts +0 -219
- package/src/body-sync-calendar-suggestion.test.ts +0 -433
- package/src/body-sync-category-denormalize.test.ts +0 -352
- package/src/body-sync-category-override.test.ts +0 -501
- package/src/body-sync-category-writeonce.test.ts +0 -234
- package/src/body-sync-filter.test.ts +0 -237
- package/src/body-sync-message-gone.test.ts +0 -139
- package/src/body-sync-placement-flags.test.ts +0 -347
- package/src/body-sync-placement-writeonce.test.ts +0 -337
- package/src/body-sync-quarantine.test.ts +0 -276
- package/src/body-sync-queue.test.ts +0 -124
- package/src/body-sync-queue.ts +0 -131
- package/src/body-sync-resync.test.ts +0 -605
- package/src/body-sync-terminal.test.ts +0 -310
- package/src/body-sync-terminal.ts +0 -176
- package/src/body-sync-unclassifiable-once.test.ts +0 -260
- package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
- package/src/body-sync.ts +0 -1970
- package/src/calendar-parts.ts +0 -41
- package/src/classification-backfill.test.ts +0 -615
- package/src/classification-backfill.ts +0 -363
- package/src/classify-message.test.ts +0 -205
- package/src/classify-message.ts +0 -116
- package/src/connection-factory.ts +0 -102
- package/src/connection-test.ts +0 -208
- package/src/credentials.test.ts +0 -181
- package/src/external-move.test.ts +0 -369
- package/src/external-move.ts +0 -272
- package/src/filters/anchor-drift.ts +0 -55
- package/src/filters/filter-config.test.ts +0 -229
- package/src/filters/filter-config.ts +0 -69
- package/src/filters/list-id.test.ts +0 -50
- package/src/filters/match.test.ts +0 -429
- package/src/filters/match.ts +0 -274
- package/src/filters/message-embedder.test.ts +0 -39
- package/src/filters/message-embedder.ts +0 -33
- package/src/filters/pipeline.test.ts +0 -330
- package/src/filters/pipeline.ts +0 -284
- package/src/flag-push-terminal.test.ts +0 -209
- package/src/flag-push.test.ts +0 -408
- package/src/flag-push.ts +0 -198
- package/src/flag-queue-stars.test.ts +0 -171
- package/src/flag-queue.ts +0 -509
- package/src/folder-placement.ts +0 -56
- package/src/heuristics/authenticationResults.test.ts +0 -189
- package/src/heuristics/authenticationResults.ts +0 -212
- package/src/heuristics/classifyByHeaders.test.ts +0 -889
- package/src/heuristics/classifyByHeaders.ts +0 -467
- package/src/heuristics/classifyPlacement.test.ts +0 -366
- package/src/heuristics/classifyPlacement.ts +0 -211
- package/src/heuristics/classifyRealMail.test.ts +0 -242
- package/src/heuristics/machineSenders.test.ts +0 -101
- package/src/heuristics/senderMismatch.test.ts +0 -346
- package/src/heuristics/senderMismatch.ts +0 -259
- package/src/imapflow-connect-retry.test.ts +0 -134
- package/src/imapflow-connection.e2e.test.ts +0 -335
- package/src/imapflow-connection.integ.test.ts +0 -316
- package/src/imapflow-connection.test.ts +0 -469
- package/src/imapflow-connection.ts +0 -1475
- package/src/index.ts +0 -350
- package/src/list-id-backfill.test.ts +0 -573
- package/src/list-id-backfill.ts +0 -274
- package/src/mailbox-cursor-rebuild.test.ts +0 -171
- package/src/mailbox-cursor-rebuild.ts +0 -146
- package/src/mailbox-cursor.test.ts +0 -249
- package/src/mailbox-delete-intent.test.ts +0 -246
- package/src/mailbox-management.test.ts +0 -356
- package/src/mailbox-management.ts +0 -839
- package/src/mailbox-presence.test.ts +0 -72
- package/src/mailbox-queue.ts +0 -415
- package/src/mailbox-rename-intent.test.ts +0 -389
- package/src/mailbox-rename-subtree.test.ts +0 -664
- package/src/mailbox-sync.test.ts +0 -848
- package/src/mailbox-sync.ts +0 -724
- package/src/message-delete-junk-reconcile.test.ts +0 -117
- package/src/message-delete-no-trash.test.ts +0 -245
- package/src/message-delete-unsettled-placement.test.ts +0 -396
- package/src/message-move-copy.test.ts +0 -562
- package/src/message-move-empty-trash.test.ts +0 -326
- package/src/message-move-same-mailbox.test.ts +0 -139
- package/src/message-move-unsettled-placement.test.ts +0 -378
- package/src/message-move.ts +0 -1348
- package/src/message-parser.ts +0 -26
- package/src/message-presence.test.ts +0 -50
- package/src/message-sync-address-spoof.test.ts +0 -293
- package/src/message-sync-category.test.ts +0 -142
- package/src/message-sync-changedsince.test.ts +0 -840
- package/src/message-sync-flags.test.ts +0 -158
- package/src/message-sync-junk-harvest.test.ts +0 -244
- package/src/message-sync-quarantine.test.ts +0 -416
- package/src/message-sync-repoint.test.ts +0 -419
- package/src/message-sync.ts +0 -1963
- package/src/mime-walker.test.ts +0 -311
- package/src/mime-walker.ts +0 -305
- package/src/outbox-attachment-content.test.ts +0 -153
- package/src/outbox-attachment-content.ts +0 -104
- package/src/outbox-attachment-filename.test.ts +0 -132
- package/src/outbox-attachment-filename.ts +0 -76
- package/src/outbox-attachment.test.ts +0 -738
- package/src/outbox-attachment.ts +0 -536
- package/src/outbox-failed-recovery.test.ts +0 -370
- package/src/outbox-queue-settled-row.test.ts +0 -140
- package/src/outbox-queue.test.ts +0 -291
- package/src/outbox-queue.ts +0 -508
- package/src/pass-through-unit-of-work.ts +0 -17
- package/src/placement-move-terminal.test.ts +0 -300
- package/src/placement-move-terminal.ts +0 -148
- package/src/placement-move-unsettled.test.ts +0 -275
- package/src/placement-move.ts +0 -336
- package/src/placement-settled.test.ts +0 -147
- package/src/placement-transition-race.test.ts +0 -482
- package/src/quarantine.test.ts +0 -191
- package/src/quarantine.ts +0 -228
- package/src/snippet.test.ts +0 -213
- package/src/snippet.ts +0 -152
- package/src/spam-report.test.ts +0 -801
- package/src/spam-report.ts +0 -357
- package/src/stale-message-reconcile.test.ts +0 -82
- package/src/stale-message-reconcile.ts +0 -55
- package/src/sync-watermarks.test.ts +0 -409
- package/src/sync-watermarks.ts +0 -319
- package/src/system-flag-wire-format.test.ts +0 -77
- package/src/test-helpers/folder-roles.ts +0 -66
- package/src/test-helpers/isolated-mailbox.ts +0 -44
- package/src/test-helpers/mailfuzz-connection.ts +0 -27
- package/src/test-helpers/mime-tree-generator.ts +0 -461
- package/src/text/normalizer.test.ts +0 -223
- package/src/text/normalizer.ts +0 -140
- package/src/thread-message-rows.ts +0 -77
- package/src/types/mailparser-augment.d.ts +0 -15
- package/src/types/natural-porter-stemmer.d.ts +0 -50
- package/src/types/stopword.d.ts +0 -69
- package/src/types.ts +0 -371
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
- package/tsconfig.json +0 -8
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
|
|
2
1
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
3
|
-
|
|
4
|
-
export const isNotFoundError = (error: unknown): boolean =>
|
|
5
|
-
error instanceof Error && error.name === "NotFoundError";
|
|
6
|
-
|
|
2
|
+
export const isNotFoundError = (error) => error instanceof Error && error.name === "NotFoundError";
|
|
7
3
|
/**
|
|
8
4
|
* Every state a mailbox row can carry, for a transition whose caller decided
|
|
9
5
|
* against none of them.
|
|
@@ -18,12 +14,11 @@ export const isNotFoundError = (error: unknown): boolean =>
|
|
|
18
14
|
* recorded target for the settle.
|
|
19
15
|
*/
|
|
20
16
|
export const EVERY_MAILBOX_STATE = [
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
]
|
|
26
|
-
|
|
17
|
+
MailboxSyncStatus.synced,
|
|
18
|
+
MailboxSyncStatus.pending,
|
|
19
|
+
MailboxSyncStatus.failed,
|
|
20
|
+
MailboxSyncStatus.deleting,
|
|
21
|
+
];
|
|
27
22
|
/**
|
|
28
23
|
* Whether a folder mutation is in flight, so nothing else may touch the folder
|
|
29
24
|
* over IMAP.
|
|
@@ -57,24 +52,18 @@ export const EVERY_MAILBOX_STATE = [
|
|
|
57
52
|
* Mailbox management is judged differently — it is what establishes and removes
|
|
58
53
|
* the folder, so it terminates on a not-found row alone.
|
|
59
54
|
*/
|
|
60
|
-
export const isFolderMutationInFlight = (
|
|
61
|
-
|
|
62
|
-
): boolean =>
|
|
63
|
-
mailbox.syncStatus === MailboxSyncStatus.pending ||
|
|
64
|
-
mailbox.syncStatus === MailboxSyncStatus.deleting;
|
|
65
|
-
|
|
55
|
+
export const isFolderMutationInFlight = (mailbox) => mailbox.syncStatus === MailboxSyncStatus.pending ||
|
|
56
|
+
mailbox.syncStatus === MailboxSyncStatus.deleting;
|
|
66
57
|
/** {@link isFolderMutationInFlight} for a mailbox that has to be read first, an absent row included. */
|
|
67
|
-
export const isMailboxMutationInFlight = async (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (!mailbox) return true;
|
|
79
|
-
return isFolderMutationInFlight(mailbox);
|
|
58
|
+
export const isMailboxMutationInFlight = async (mailboxService, accountId, mailboxId) => {
|
|
59
|
+
const mailbox = await mailboxService
|
|
60
|
+
.get(accountId, mailboxId)
|
|
61
|
+
.catch((error) => {
|
|
62
|
+
if (isNotFoundError(error))
|
|
63
|
+
return undefined;
|
|
64
|
+
throw error;
|
|
65
|
+
});
|
|
66
|
+
if (!mailbox)
|
|
67
|
+
return true;
|
|
68
|
+
return isFolderMutationInFlight(mailbox);
|
|
80
69
|
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { CreateMailboxInput, CreateMailboxResult, FifoQueueProducer, IMailboxRepository, MailboxItem } from "@remit/data-ports";
|
|
2
|
+
/**
|
|
3
|
+
* MAILBOX_CREATE event structure (matches remit-imap-worker/events.ts)
|
|
4
|
+
*/
|
|
5
|
+
export interface MailboxCreateEvent {
|
|
6
|
+
type: "MAILBOX_CREATE";
|
|
7
|
+
eventId: string;
|
|
8
|
+
timestamp: number;
|
|
9
|
+
accountId: string;
|
|
10
|
+
mailboxId: string;
|
|
11
|
+
path: string;
|
|
12
|
+
subscribe?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* MAILBOX_RENAME event structure (matches remit-imap-worker/events.ts)
|
|
16
|
+
*/
|
|
17
|
+
export interface MailboxRenameEvent {
|
|
18
|
+
type: "MAILBOX_RENAME";
|
|
19
|
+
eventId: string;
|
|
20
|
+
timestamp: number;
|
|
21
|
+
accountId: string;
|
|
22
|
+
mailboxId: string;
|
|
23
|
+
oldPath: string;
|
|
24
|
+
newPath: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* MAILBOX_DELETE event structure (matches remit-imap-worker/events.ts)
|
|
28
|
+
*/
|
|
29
|
+
export interface MailboxDeleteEvent {
|
|
30
|
+
type: "MAILBOX_DELETE";
|
|
31
|
+
eventId: string;
|
|
32
|
+
timestamp: number;
|
|
33
|
+
accountId: string;
|
|
34
|
+
mailboxId: string;
|
|
35
|
+
path: string;
|
|
36
|
+
}
|
|
37
|
+
export type MailboxManagementEvent = MailboxCreateEvent | MailboxRenameEvent | MailboxDeleteEvent;
|
|
38
|
+
/**
|
|
39
|
+
* Logger interface
|
|
40
|
+
*/
|
|
41
|
+
export interface MailboxQueueLogger {
|
|
42
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
43
|
+
error(obj: Record<string, unknown>, msg: string): void;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Input for creating a mailbox via the queue service
|
|
47
|
+
*/
|
|
48
|
+
export type CreateMailboxQueueInput = Omit<CreateMailboxInput, "syncStatus">;
|
|
49
|
+
/**
|
|
50
|
+
* Configuration for MailboxQueueService
|
|
51
|
+
*/
|
|
52
|
+
export interface MailboxQueueConfig {
|
|
53
|
+
mailboxService: IMailboxRepository;
|
|
54
|
+
queue: FifoQueueProducer<MailboxManagementEvent>;
|
|
55
|
+
logger?: MailboxQueueLogger;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Service for mailbox management with automatic IMAP sync queueing.
|
|
59
|
+
*
|
|
60
|
+
* Implements optimistic local-first pattern:
|
|
61
|
+
* 1. Updates local state immediately
|
|
62
|
+
* 2. Enqueues mailbox management event to SQS for worker to sync to IMAP
|
|
63
|
+
*
|
|
64
|
+
* This follows the same pattern as FlagQueueService (RFC 014).
|
|
65
|
+
*/
|
|
66
|
+
export declare class MailboxQueueService {
|
|
67
|
+
private mailboxService;
|
|
68
|
+
private queue;
|
|
69
|
+
private log;
|
|
70
|
+
constructor(config: MailboxQueueConfig);
|
|
71
|
+
/**
|
|
72
|
+
* Create a new mailbox.
|
|
73
|
+
* Updates local state (with syncStatus=pending) and enqueues IMAP CREATE.
|
|
74
|
+
*
|
|
75
|
+
* @param input - The mailbox creation input (without syncStatus)
|
|
76
|
+
* @param accountId - The account ID for the IMAP sync event
|
|
77
|
+
* @param subscribe - Whether to subscribe to the mailbox after creation
|
|
78
|
+
* @returns The created mailbox, or `PathTaken` when the account already has a folder at that path
|
|
79
|
+
*/
|
|
80
|
+
createMailbox: (input: CreateMailboxQueueInput, accountId: string, subscribe?: boolean) => Promise<CreateMailboxResult>;
|
|
81
|
+
/**
|
|
82
|
+
* Record a rename intent over the folder and every descendant, then enqueue
|
|
83
|
+
* IMAP RENAME.
|
|
84
|
+
*
|
|
85
|
+
* The target is recorded, not the path (D2): every row keeps the `fullPath`
|
|
86
|
+
* the server holds and takes its own rewritten target in `pendingPath`, so a
|
|
87
|
+
* rename that never lands needs no unwinding — dropping `pendingPath` is the
|
|
88
|
+
* whole revert — and nothing that resolves a folder path from a row can
|
|
89
|
+
* resolve one the server does not have.
|
|
90
|
+
*
|
|
91
|
+
* @param mailboxId - The mailbox to rename
|
|
92
|
+
* @param newPath - The path the rename is aiming at
|
|
93
|
+
* @param accountId - The account ID for the IMAP sync event
|
|
94
|
+
* @returns The folder that was named, carrying its recorded target
|
|
95
|
+
*/
|
|
96
|
+
renameMailbox: (mailboxId: string, newPath: string, accountId: string) => Promise<MailboxItem>;
|
|
97
|
+
/**
|
|
98
|
+
* The route out of a failed rename or a failed delete that keeps the folder
|
|
99
|
+
* as it is (T10, T11): the recorded target is dropped and the row settles
|
|
100
|
+
* back to `synced`. Nothing is enqueued — `fullPath` was never written, so
|
|
101
|
+
* the server has nothing to undo.
|
|
102
|
+
*
|
|
103
|
+
* Reached without new API surface, from a PATCH whose `fullPath` equals the
|
|
104
|
+
* row's confirmed one. From `synced` it is a no-op; from a state with a
|
|
105
|
+
* mutation in flight it is a 409, because dismissing an intent that is still
|
|
106
|
+
* running would leave the settle with no row to write.
|
|
107
|
+
*/
|
|
108
|
+
dismissMailboxIntent: (mailboxId: string, accountId: string) => Promise<MailboxItem>;
|
|
109
|
+
/**
|
|
110
|
+
* Record a delete intent (T7) and enqueue IMAP DELETE.
|
|
111
|
+
*
|
|
112
|
+
* The row stays at `deleting` and stays visible until the worker confirms the
|
|
113
|
+
* server delete and removes it with the folder's mail (D8, D11). The enqueue
|
|
114
|
+
* happens only after the transition wins, and an enqueue failure stays thrown
|
|
115
|
+
* — a 500 to the caller, loud — rather than being swallowed or rolled back.
|
|
116
|
+
*
|
|
117
|
+
* @param mailboxId - The mailbox to delete
|
|
118
|
+
* @param accountId - The account ID for the IMAP sync event
|
|
119
|
+
*/
|
|
120
|
+
deleteMailbox: (mailboxId: string, accountId: string) => Promise<void>;
|
|
121
|
+
/**
|
|
122
|
+
* Enqueue a mailbox management event, ordered per account so events for
|
|
123
|
+
* different accounts can be processed in parallel.
|
|
124
|
+
*/
|
|
125
|
+
private enqueueEvent;
|
|
126
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
3
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
4
|
+
import { INTENT_RECORDABLE_FROM, recordedByRename, refuseContestedIntent, } from "./mailbox-intent.js";
|
|
5
|
+
const noopLogger = {
|
|
6
|
+
info: () => { },
|
|
7
|
+
error: () => { },
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Service for mailbox management with automatic IMAP sync queueing.
|
|
11
|
+
*
|
|
12
|
+
* Implements optimistic local-first pattern:
|
|
13
|
+
* 1. Updates local state immediately
|
|
14
|
+
* 2. Enqueues mailbox management event to SQS for worker to sync to IMAP
|
|
15
|
+
*
|
|
16
|
+
* This follows the same pattern as FlagQueueService (RFC 014).
|
|
17
|
+
*/
|
|
18
|
+
export class MailboxQueueService {
|
|
19
|
+
mailboxService;
|
|
20
|
+
queue;
|
|
21
|
+
log;
|
|
22
|
+
constructor(config) {
|
|
23
|
+
this.mailboxService = config.mailboxService;
|
|
24
|
+
this.queue = config.queue;
|
|
25
|
+
this.log = config.logger ?? noopLogger;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Create a new mailbox.
|
|
29
|
+
* Updates local state (with syncStatus=pending) and enqueues IMAP CREATE.
|
|
30
|
+
*
|
|
31
|
+
* @param input - The mailbox creation input (without syncStatus)
|
|
32
|
+
* @param accountId - The account ID for the IMAP sync event
|
|
33
|
+
* @param subscribe - Whether to subscribe to the mailbox after creation
|
|
34
|
+
* @returns The created mailbox, or `PathTaken` when the account already has a folder at that path
|
|
35
|
+
*/
|
|
36
|
+
createMailbox = async (input, accountId, subscribe) => {
|
|
37
|
+
const created = await this.mailboxService.create({
|
|
38
|
+
...input,
|
|
39
|
+
syncStatus: MailboxSyncStatus.pending,
|
|
40
|
+
});
|
|
41
|
+
if (created.outcome === "PathTaken")
|
|
42
|
+
return created;
|
|
43
|
+
const { mailbox } = created;
|
|
44
|
+
this.log.info({ mailboxId: mailbox.mailboxId, path: mailbox.fullPath }, "Created mailbox (local)");
|
|
45
|
+
// Enqueue IMAP sync
|
|
46
|
+
await this.enqueueEvent({
|
|
47
|
+
type: "MAILBOX_CREATE",
|
|
48
|
+
eventId: randomUUID(),
|
|
49
|
+
timestamp: Date.now(),
|
|
50
|
+
accountId,
|
|
51
|
+
mailboxId: mailbox.mailboxId,
|
|
52
|
+
path: mailbox.fullPath,
|
|
53
|
+
subscribe,
|
|
54
|
+
});
|
|
55
|
+
return created;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Record a rename intent over the folder and every descendant, then enqueue
|
|
59
|
+
* IMAP RENAME.
|
|
60
|
+
*
|
|
61
|
+
* The target is recorded, not the path (D2): every row keeps the `fullPath`
|
|
62
|
+
* the server holds and takes its own rewritten target in `pendingPath`, so a
|
|
63
|
+
* rename that never lands needs no unwinding — dropping `pendingPath` is the
|
|
64
|
+
* whole revert — and nothing that resolves a folder path from a row can
|
|
65
|
+
* resolve one the server does not have.
|
|
66
|
+
*
|
|
67
|
+
* @param mailboxId - The mailbox to rename
|
|
68
|
+
* @param newPath - The path the rename is aiming at
|
|
69
|
+
* @param accountId - The account ID for the IMAP sync event
|
|
70
|
+
* @returns The folder that was named, carrying its recorded target
|
|
71
|
+
*/
|
|
72
|
+
renameMailbox = async (mailboxId, newPath, accountId) => {
|
|
73
|
+
// Get current mailbox to capture old path
|
|
74
|
+
const mailbox = await this.mailboxService.get(accountId, mailboxId);
|
|
75
|
+
const oldPath = mailbox.fullPath;
|
|
76
|
+
// One intent over the folder and every descendant, in one batch:
|
|
77
|
+
// IMAP RENAME moves the subtree in one command, so a partially recorded
|
|
78
|
+
// rename is the state that produced today's phantom rows (D6). A
|
|
79
|
+
// descendant already mid-mutation refuses the whole rename.
|
|
80
|
+
const written = await this.mailboxService.transitionSubtree(accountId, mailboxId, {
|
|
81
|
+
from: INTENT_RECORDABLE_FROM,
|
|
82
|
+
to: MailboxSyncStatus.pending,
|
|
83
|
+
newPath,
|
|
84
|
+
});
|
|
85
|
+
if (!written) {
|
|
86
|
+
return refuseContestedIntent(this.mailboxService, accountId, mailboxId, "subtree");
|
|
87
|
+
}
|
|
88
|
+
const updated = written.find((row) => row.mailboxId === mailboxId);
|
|
89
|
+
if (!updated)
|
|
90
|
+
throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
91
|
+
this.log.info({
|
|
92
|
+
accountId,
|
|
93
|
+
mailboxId,
|
|
94
|
+
intent: "rename",
|
|
95
|
+
from: mailbox.syncStatus,
|
|
96
|
+
to: MailboxSyncStatus.pending,
|
|
97
|
+
oldPath,
|
|
98
|
+
newPath,
|
|
99
|
+
subtreeSize: written.length,
|
|
100
|
+
}, "Recorded rename intent");
|
|
101
|
+
// Enqueue IMAP sync
|
|
102
|
+
await this.enqueueEvent({
|
|
103
|
+
type: "MAILBOX_RENAME",
|
|
104
|
+
eventId: randomUUID(),
|
|
105
|
+
timestamp: Date.now(),
|
|
106
|
+
accountId,
|
|
107
|
+
mailboxId,
|
|
108
|
+
oldPath,
|
|
109
|
+
newPath,
|
|
110
|
+
});
|
|
111
|
+
return updated;
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* The route out of a failed rename or a failed delete that keeps the folder
|
|
115
|
+
* as it is (T10, T11): the recorded target is dropped and the row settles
|
|
116
|
+
* back to `synced`. Nothing is enqueued — `fullPath` was never written, so
|
|
117
|
+
* the server has nothing to undo.
|
|
118
|
+
*
|
|
119
|
+
* Reached without new API surface, from a PATCH whose `fullPath` equals the
|
|
120
|
+
* row's confirmed one. From `synced` it is a no-op; from a state with a
|
|
121
|
+
* mutation in flight it is a 409, because dismissing an intent that is still
|
|
122
|
+
* running would leave the settle with no row to write.
|
|
123
|
+
*/
|
|
124
|
+
dismissMailboxIntent = async (mailboxId, accountId) => {
|
|
125
|
+
const current = await this.mailboxService.get(accountId, mailboxId);
|
|
126
|
+
if (current.syncStatus === MailboxSyncStatus.synced)
|
|
127
|
+
return current;
|
|
128
|
+
if (current.syncStatus !== MailboxSyncStatus.failed) {
|
|
129
|
+
return refuseContestedIntent(this.mailboxService, accountId, mailboxId, "folder");
|
|
130
|
+
}
|
|
131
|
+
// A failed rename was recorded over a subtree, so it is dismissed over the
|
|
132
|
+
// same one. A failed delete carries no target and was recorded on the
|
|
133
|
+
// folder alone, so there is nothing else to clear.
|
|
134
|
+
const target = current.pendingPath;
|
|
135
|
+
const alsoRecorded = target === undefined
|
|
136
|
+
? []
|
|
137
|
+
: (await this.mailboxService.findBySyncStatus(accountId, MailboxSyncStatus.failed)).filter((row) => row.mailboxId !== mailboxId &&
|
|
138
|
+
recordedByRename(row, current.fullPath, target, current.hierarchyDelimiter));
|
|
139
|
+
let dismissed;
|
|
140
|
+
// The named folder first, so a caller always gets back the row it asked
|
|
141
|
+
// about. Each write carries its own predicate, so a row somebody else
|
|
142
|
+
// moved between the read and the write is skipped, not overwritten (D3).
|
|
143
|
+
for (const row of [current, ...alsoRecorded]) {
|
|
144
|
+
const written = await this.mailboxService.transition(accountId, row.mailboxId, {
|
|
145
|
+
from: [MailboxSyncStatus.failed],
|
|
146
|
+
wherePendingPath: row.pendingPath ?? null,
|
|
147
|
+
to: MailboxSyncStatus.synced,
|
|
148
|
+
});
|
|
149
|
+
if (row.mailboxId === mailboxId)
|
|
150
|
+
dismissed = written ?? undefined;
|
|
151
|
+
this.log.info({
|
|
152
|
+
accountId,
|
|
153
|
+
mailboxId: row.mailboxId,
|
|
154
|
+
intent: "dismiss",
|
|
155
|
+
from: MailboxSyncStatus.failed,
|
|
156
|
+
to: MailboxSyncStatus.synced,
|
|
157
|
+
outcome: written ? "settled" : "superseded",
|
|
158
|
+
}, "Dismissed folder intent");
|
|
159
|
+
}
|
|
160
|
+
if (dismissed)
|
|
161
|
+
return dismissed;
|
|
162
|
+
// The row moved between the read and its write. Re-read rather than
|
|
163
|
+
// reporting a dismissal that did not happen.
|
|
164
|
+
return this.mailboxService.get(accountId, mailboxId);
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* Record a delete intent (T7) and enqueue IMAP DELETE.
|
|
168
|
+
*
|
|
169
|
+
* The row stays at `deleting` and stays visible until the worker confirms the
|
|
170
|
+
* server delete and removes it with the folder's mail (D8, D11). The enqueue
|
|
171
|
+
* happens only after the transition wins, and an enqueue failure stays thrown
|
|
172
|
+
* — a 500 to the caller, loud — rather than being swallowed or rolled back.
|
|
173
|
+
*
|
|
174
|
+
* @param mailboxId - The mailbox to delete
|
|
175
|
+
* @param accountId - The account ID for the IMAP sync event
|
|
176
|
+
*/
|
|
177
|
+
deleteMailbox = async (mailboxId, accountId) => {
|
|
178
|
+
// Get current mailbox to capture path
|
|
179
|
+
const mailbox = await this.mailboxService.get(accountId, mailboxId);
|
|
180
|
+
const recorded = await this.mailboxService.transition(accountId, mailboxId, { from: INTENT_RECORDABLE_FROM, to: MailboxSyncStatus.deleting });
|
|
181
|
+
if (!recorded) {
|
|
182
|
+
await refuseContestedIntent(this.mailboxService, accountId, mailboxId, "folder");
|
|
183
|
+
}
|
|
184
|
+
this.log.info({
|
|
185
|
+
accountId,
|
|
186
|
+
mailboxId,
|
|
187
|
+
intent: "delete",
|
|
188
|
+
from: mailbox.syncStatus,
|
|
189
|
+
to: MailboxSyncStatus.deleting,
|
|
190
|
+
path: mailbox.fullPath,
|
|
191
|
+
}, "Recorded delete intent");
|
|
192
|
+
// Enqueue IMAP sync
|
|
193
|
+
await this.enqueueEvent({
|
|
194
|
+
type: "MAILBOX_DELETE",
|
|
195
|
+
eventId: randomUUID(),
|
|
196
|
+
timestamp: Date.now(),
|
|
197
|
+
accountId,
|
|
198
|
+
mailboxId,
|
|
199
|
+
path: mailbox.fullPath,
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
/**
|
|
203
|
+
* Enqueue a mailbox management event, ordered per account so events for
|
|
204
|
+
* different accounts can be processed in parallel.
|
|
205
|
+
*/
|
|
206
|
+
enqueueEvent = async (event) => {
|
|
207
|
+
await this.queue.send({
|
|
208
|
+
body: event,
|
|
209
|
+
groupId: event.accountId,
|
|
210
|
+
deduplicationId: `${event.type}:${event.mailboxId}:${event.eventId}`,
|
|
211
|
+
});
|
|
212
|
+
this.log.info({ eventId: event.eventId, type: event.type, mailboxId: event.mailboxId }, `Enqueued ${event.type} event`);
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mailbox synchronization service
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates syncing mailbox data from IMAP server to the data backend.
|
|
5
|
+
*/
|
|
6
|
+
import type { IMailboxRepository, IMailboxSpecialUseRepository, MailboxItem } from "@remit/data-ports";
|
|
7
|
+
import type { IImapConnection, MailboxSyncResult } from "./types.js";
|
|
8
|
+
/**
|
|
9
|
+
* Account info needed for mailbox sync
|
|
10
|
+
*/
|
|
11
|
+
export interface SyncAccountInfo {
|
|
12
|
+
accountId: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Logger interface for MailboxSyncService.
|
|
16
|
+
*
|
|
17
|
+
* `info` is reserved here for the three mailbox lifecycle events — created,
|
|
18
|
+
* updated, deleted — and is the argued exception to `plugins/no-logger-info.grit`
|
|
19
|
+
* that the rest of this repo writes as a suppression. (The plugin matches the
|
|
20
|
+
* bare `logger` identifier, so it does not see an injected logger; the reasoning
|
|
21
|
+
* is written out instead of being silently absent.) A folder disappearing from
|
|
22
|
+
* someone's mail is destructive and irreversible from this service's side, and
|
|
23
|
+
* the only record that this process did it is the line it writes. That must not
|
|
24
|
+
* sit below the default threshold.
|
|
25
|
+
*
|
|
26
|
+
* Everything else this service has to say — a special-use flag it synced, a
|
|
27
|
+
* duplicate folder it skipped — is a routine trace and goes to `debug`.
|
|
28
|
+
*/
|
|
29
|
+
export interface MailboxSyncLogger {
|
|
30
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
31
|
+
debug(obj: Record<string, unknown>, msg: string): void;
|
|
32
|
+
error(obj: Record<string, unknown>, msg: string): void;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Service for synchronizing mailbox metadata between IMAP and the data backend.
|
|
36
|
+
*/
|
|
37
|
+
export declare class MailboxSyncService {
|
|
38
|
+
private mailboxService;
|
|
39
|
+
private specialUseService;
|
|
40
|
+
private log;
|
|
41
|
+
constructor(mailboxService: IMailboxRepository, specialUseService: IMailboxSpecialUseRepository, logger: MailboxSyncLogger);
|
|
42
|
+
/**
|
|
43
|
+
* Sync all mailboxes for an account from IMAP server
|
|
44
|
+
*
|
|
45
|
+
* @param account - Account info including accountId
|
|
46
|
+
* @param connection - Active IMAP connection
|
|
47
|
+
*/
|
|
48
|
+
syncMailboxes: (account: SyncAccountInfo, connection: IImapConnection) => Promise<MailboxSyncResult>;
|
|
49
|
+
/**
|
|
50
|
+
* Sync metadata for a specific mailbox
|
|
51
|
+
*
|
|
52
|
+
* Opens the mailbox to get current UID validity, counts, etc.
|
|
53
|
+
*/
|
|
54
|
+
syncMailboxMetadata: (accountId: string, mailboxId: string, connection: IImapConnection) => Promise<MailboxItem>;
|
|
55
|
+
/**
|
|
56
|
+
* Get all mailboxes for an account, handling pagination
|
|
57
|
+
*/
|
|
58
|
+
private getAllMailboxes;
|
|
59
|
+
/**
|
|
60
|
+
* Fetch all mailboxes from IMAP server across all namespaces.
|
|
61
|
+
* Uses listMailboxes() to preserve original paths from the server.
|
|
62
|
+
*/
|
|
63
|
+
private fetchAllMailboxes;
|
|
64
|
+
/**
|
|
65
|
+
* Create a new mailbox in the database
|
|
66
|
+
*/
|
|
67
|
+
private createMailbox;
|
|
68
|
+
/**
|
|
69
|
+
* Update an existing mailbox with fresh data.
|
|
70
|
+
* Skips DB write if nothing has changed.
|
|
71
|
+
*
|
|
72
|
+
* @returns The updated mailbox, or null if skipped due to no changes
|
|
73
|
+
*/
|
|
74
|
+
private updateMailbox;
|
|
75
|
+
/**
|
|
76
|
+
* Sync special-use attributes for a mailbox.
|
|
77
|
+
* Creates entries if they don't exist, updates if changed.
|
|
78
|
+
*/
|
|
79
|
+
private syncSpecialUseAttributes;
|
|
80
|
+
/**
|
|
81
|
+
* Build a map of special-use designations to mailbox paths.
|
|
82
|
+
* Only includes mailboxes that have the IMAP special-use attribute.
|
|
83
|
+
*/
|
|
84
|
+
private buildSpecialUseMap;
|
|
85
|
+
/**
|
|
86
|
+
* Check if a mailbox is a duplicate special-use folder.
|
|
87
|
+
* A folder is considered duplicate if:
|
|
88
|
+
* 1. Its name matches a common special-use folder name (e.g., "Trash")
|
|
89
|
+
* 2. It does NOT have the IMAP special-use attribute
|
|
90
|
+
* 3. Another folder already claimed that special-use designation
|
|
91
|
+
*/
|
|
92
|
+
private isDuplicateSpecialUse;
|
|
93
|
+
}
|