@remit/mailbox-service 0.0.112 → 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} +16 -72
- 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} +38 -64
- 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} +13 -68
- 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 -97
- 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 -88
- package/src/account-credentials.ts +0 -210
- 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 -410
- package/src/body-sync-queue.test.ts +0 -64
- package/src/body-sync-queue.ts +0 -131
- package/src/body-sync-terminal.test.ts +0 -148
- package/src/body-sync-terminal.ts +0 -174
- package/src/body-sync.test.ts +0 -925
- package/src/body-sync.ts +0 -1891
- package/src/calendar-parts.ts +0 -41
- package/src/classification-backfill.test.ts +0 -285
- package/src/classification-backfill.ts +0 -363
- package/src/classify-message.test.ts +0 -142
- 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 -262
- package/src/external-move.ts +0 -285
- package/src/filter-operations.test.ts +0 -319
- package/src/filter-operations.ts +0 -377
- package/src/filter-update.test.ts +0 -313
- package/src/filters/filter-config.test.ts +0 -87
- package/src/filters/filter-config.ts +0 -34
- package/src/filters/list-id.test.ts +0 -50
- package/src/filters/match.test.ts +0 -392
- package/src/filters/match.ts +0 -225
- package/src/filters/pipeline.test.ts +0 -199
- package/src/filters/pipeline.ts +0 -170
- package/src/flag-push.test.ts +0 -112
- package/src/flag-push.ts +0 -198
- package/src/flag-queue.test.ts +0 -197
- package/src/flag-queue.ts +0 -509
- package/src/folder-operations.test.ts +0 -273
- package/src/folder-operations.ts +0 -217
- package/src/folder-placement.ts +0 -56
- package/src/forwarded-message.test.ts +0 -122
- 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 -97
- package/src/imapflow-connection.test.ts +0 -503
- package/src/imapflow-connection.ts +0 -1476
- package/src/index.ts +0 -397
- package/src/list-id-backfill.test.ts +0 -207
- 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 -141
- package/src/mailbox-delete-intent.test.ts +0 -119
- package/src/mailbox-management.test.ts +0 -243
- package/src/mailbox-management.ts +0 -838
- package/src/mailbox-presence.test.ts +0 -74
- package/src/mailbox-queue.ts +0 -397
- package/src/mailbox-rename-intent.test.ts +0 -220
- package/src/mailbox-rename-subtree.test.ts +0 -287
- package/src/mailbox-sync.test.ts +0 -286
- package/src/mailbox-sync.ts +0 -724
- package/src/message-move.test.ts +0 -726
- package/src/message-move.ts +0 -1518
- package/src/message-parser.ts +0 -26
- package/src/message-presence.test.ts +0 -50
- package/src/message-read.ts +0 -87
- package/src/message-save-retry.test.ts +0 -90
- package/src/message-sync-flags.test.ts +0 -167
- package/src/message-sync.test.ts +0 -762
- package/src/message-sync.ts +0 -2133
- package/src/mime-walker.test.ts +0 -311
- package/src/mime-walker.ts +0 -306
- package/src/organize-job.ts +0 -93
- 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 -626
- package/src/outbox-attachment.ts +0 -536
- package/src/outbox-failed-recovery.test.ts +0 -299
- package/src/outbox-queue.test.ts +0 -315
- package/src/outbox-queue.ts +0 -525
- package/src/ownership.ts +0 -140
- package/src/placement-move-terminal.ts +0 -145
- package/src/placement-move.ts +0 -363
- package/src/placement-settled.test.ts +0 -147
- package/src/placement.test.ts +0 -404
- package/src/quarantine.test.ts +0 -133
- package/src/quarantine.ts +0 -229
- package/src/refusal.ts +0 -29
- package/src/snippet.test.ts +0 -213
- package/src/snippet.ts +0 -152
- package/src/spam-report.test.ts +0 -329
- package/src/spam-report.ts +0 -357
- package/src/sync-warning.test.ts +0 -213
- package/src/sync-warning.ts +0 -56
- 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/fake-imap.ts +0 -690
- package/src/test-helpers/folder-roles.ts +0 -66
- package/src/test-helpers/mime-tree-generator.ts +0 -461
- package/src/test-helpers/test-queue.ts +0 -56
- package/src/test-helpers/test-store.ts +0 -299
- 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/thread-root.test.ts +0 -29
- package/src/thread-root.ts +0 -51
- package/src/types/mailparser-augment.d.ts +0 -23
- 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/tsconfig.json +0 -8
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
2
|
+
import { rebaseMailboxPath } from "@remit/data-ports/mailbox-name";
|
|
3
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
4
|
+
import { recordedByRename } from "./mailbox-intent.js";
|
|
5
|
+
import { isNotFoundError } from "./mailbox-presence.js";
|
|
6
|
+
import { FolderGoneUpstreamError, FolderRenameSettleError, isMailboxAbsentUpstream, } from "./mailbox-upstream.js";
|
|
7
|
+
const noopLogger = {
|
|
8
|
+
info: () => { },
|
|
9
|
+
error: () => { },
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Parse a mailbox path to extract name and parent path
|
|
13
|
+
*/
|
|
14
|
+
export const parseMailboxPath = (path, delimiter = "/") => {
|
|
15
|
+
const parts = path.split(delimiter);
|
|
16
|
+
return {
|
|
17
|
+
name: parts[parts.length - 1],
|
|
18
|
+
parent: parts.length > 1 ? parts.slice(0, -1).join(delimiter) : null,
|
|
19
|
+
depth: parts.length,
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Validate mailbox path for invalid characters and operations
|
|
24
|
+
*/
|
|
25
|
+
export const validateMailboxPath = (path) => {
|
|
26
|
+
if (!path || path.trim().length === 0) {
|
|
27
|
+
throw new Error("Mailbox path cannot be empty");
|
|
28
|
+
}
|
|
29
|
+
// Check for double delimiters
|
|
30
|
+
if (path.includes("//")) {
|
|
31
|
+
throw new Error("Mailbox path cannot contain empty hierarchy levels");
|
|
32
|
+
}
|
|
33
|
+
// Check for leading/trailing delimiters
|
|
34
|
+
if (path.startsWith("/") || path.endsWith("/")) {
|
|
35
|
+
throw new Error("Mailbox path cannot start or end with hierarchy delimiter");
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Validate that an operation can be performed on a mailbox path
|
|
40
|
+
*/
|
|
41
|
+
export const validateMailboxOperation = (operation, path) => {
|
|
42
|
+
if (path.toUpperCase() !== "INBOX")
|
|
43
|
+
return;
|
|
44
|
+
// A RENAME of INBOX moves its mail to the new name and leaves an empty INBOX
|
|
45
|
+
// behind — a bulk move wearing a rename's clothes, which strands the
|
|
46
|
+
// account's inbox appointment and voids the row's UID cursor (D5). The API
|
|
47
|
+
// refuses it; this is the backstop for anything that reaches the worker
|
|
48
|
+
// another way.
|
|
49
|
+
throw new Error(operation === "delete" ? "Cannot delete INBOX" : "Cannot rename INBOX");
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Whether the server's own listing holds a path this code named.
|
|
53
|
+
*
|
|
54
|
+
* The requested string and the path the server keeps are not the same thing.
|
|
55
|
+
* Under a namespace prefix a Dovecot INBOX namespace stores `Projects` as
|
|
56
|
+
* `INBOX.Projects`, which is why the settle adopts the path ImapFlow resolved
|
|
57
|
+
* rather than the one that was asked for (D2) — and a probe comparing the raw
|
|
58
|
+
* request against the listing would read a folder that is plainly there as
|
|
59
|
+
* absent. There is no resolved path to adopt on the failure path, so the
|
|
60
|
+
* account's own prefix stands in for the normalization.
|
|
61
|
+
*/
|
|
62
|
+
const holdsPath = (onServer, mailbox, path) => {
|
|
63
|
+
if (onServer.has(path))
|
|
64
|
+
return true;
|
|
65
|
+
const { namespacePrefix } = mailbox;
|
|
66
|
+
if (namespacePrefix.length === 0)
|
|
67
|
+
return false;
|
|
68
|
+
if (path.startsWith(namespacePrefix))
|
|
69
|
+
return false;
|
|
70
|
+
return onServer.has(`${namespacePrefix}${path}`);
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Service for managing mailbox operations (create, rename, delete).
|
|
74
|
+
*
|
|
75
|
+
* Implements an optimistic local-first pattern:
|
|
76
|
+
* 1. Updates are applied locally first
|
|
77
|
+
* 2. Changes are queued for IMAP sync via SQS
|
|
78
|
+
* 3. Worker processes queue and syncs to IMAP server
|
|
79
|
+
*/
|
|
80
|
+
export class MailboxManagementService {
|
|
81
|
+
mailboxService;
|
|
82
|
+
log;
|
|
83
|
+
constructor(mailboxService, logger) {
|
|
84
|
+
this.mailboxService = mailboxService;
|
|
85
|
+
this.log = logger ?? noopLogger;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Settle a create: `pending` with **no** recorded rename target, which is the
|
|
89
|
+
* state a create in flight is (D3, D10).
|
|
90
|
+
*
|
|
91
|
+
* The absence check is what the whole `wherePendingPath` predicate exists
|
|
92
|
+
* for. A create whose acknowledgement was lost, redelivered against a row a
|
|
93
|
+
* rename has since claimed, passes a `pending`-only guard; the CREATE
|
|
94
|
+
* collides, ImapFlow reports the collision as `{created: false}` rather than
|
|
95
|
+
* an error, and this write then strands the row `synced` with a rename
|
|
96
|
+
* target on it — the seventh combination — leaving the rename's own settle
|
|
97
|
+
* with nothing to match and the RENAME never running.
|
|
98
|
+
*
|
|
99
|
+
* A null means the row is gone or the intent moved on. The handlers'
|
|
100
|
+
* #289-class terminal guards classify the NotFoundError as the user having
|
|
101
|
+
* deleted the folder mid-sync.
|
|
102
|
+
*/
|
|
103
|
+
settleCreate = async (accountId, mailboxId, confirmedPath) => {
|
|
104
|
+
const settled = await this.mailboxService.transition(accountId, mailboxId, {
|
|
105
|
+
from: [MailboxSyncStatus.pending],
|
|
106
|
+
wherePendingPath: null,
|
|
107
|
+
to: MailboxSyncStatus.synced,
|
|
108
|
+
set: confirmedPath !== undefined ? { fullPath: confirmedPath } : {},
|
|
109
|
+
});
|
|
110
|
+
if (settled)
|
|
111
|
+
return;
|
|
112
|
+
// Either the row is gone, or a rename claimed it while this create was in
|
|
113
|
+
// flight. Both resolve the job: the folder exists, and the rename is the
|
|
114
|
+
// live intent.
|
|
115
|
+
const current = await this.mailboxService
|
|
116
|
+
.get(accountId, mailboxId)
|
|
117
|
+
.catch((error) => {
|
|
118
|
+
if (isNotFoundError(error))
|
|
119
|
+
return undefined;
|
|
120
|
+
throw error;
|
|
121
|
+
});
|
|
122
|
+
if (!current)
|
|
123
|
+
throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
124
|
+
this.log.info({
|
|
125
|
+
accountId,
|
|
126
|
+
mailboxId,
|
|
127
|
+
intent: "create",
|
|
128
|
+
from: current.syncStatus,
|
|
129
|
+
outcome: "superseded",
|
|
130
|
+
}, "Folder create superseded");
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* The rows one rename intent recorded, each with the path it settles to.
|
|
134
|
+
*
|
|
135
|
+
* The settle deliberately does not re-resolve the subtree (D15): membership
|
|
136
|
+
* changes between the intent and the settle — the sweep inserts a row,
|
|
137
|
+
* another client deletes a descendant — and an all-or-nothing settle then
|
|
138
|
+
* either throws, poisoning the account's FIFO group (#339), or resolves
|
|
139
|
+
* leaving every row `pending` forever with no route out, because `failed` is
|
|
140
|
+
* only reachable from a settle that ran.
|
|
141
|
+
*
|
|
142
|
+
* So the rows identify themselves, by the pair `recordedByRename` tests: a
|
|
143
|
+
* target at or under the rename's target is not on its own enough, because a
|
|
144
|
+
* second folder renamed under the same branch would be claimed by a rename
|
|
145
|
+
* that never touched it. A row that appeared afterwards carries no such pair
|
|
146
|
+
* and is not touched.
|
|
147
|
+
*
|
|
148
|
+
* `confirmedPath` re-prefixes each recorded target off the path the rename
|
|
149
|
+
* actually resolved to, so a normalization the requested string did not carry
|
|
150
|
+
* moves the whole branch rather than the folder alone.
|
|
151
|
+
*
|
|
152
|
+
* `delimiter` is the renamed folder's own, read from the row the caller
|
|
153
|
+
* already holds — deriving one from whatever the listing happens to contain
|
|
154
|
+
* strands every descendant when that row has gone.
|
|
155
|
+
*/
|
|
156
|
+
intentCarryingRows = async (accountId, oldPath, target, confirmed, delimiter) => {
|
|
157
|
+
const pending = await this.mailboxService.findBySyncStatus(accountId, MailboxSyncStatus.pending);
|
|
158
|
+
const carrying = [];
|
|
159
|
+
for (const row of pending) {
|
|
160
|
+
if (!recordedByRename(row, oldPath, target, delimiter))
|
|
161
|
+
continue;
|
|
162
|
+
const confirmedPath = rebaseMailboxPath(row.pendingPath, target, confirmed, delimiter);
|
|
163
|
+
if (confirmedPath === undefined)
|
|
164
|
+
continue;
|
|
165
|
+
carrying.push({ row, confirmedPath });
|
|
166
|
+
}
|
|
167
|
+
return carrying;
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* Sync a CREATE operation to IMAP.
|
|
171
|
+
* Called by worker after dequeuing MAILBOX_CREATE event.
|
|
172
|
+
*
|
|
173
|
+
* @param accountId - Account that owns the mailbox (tenant scope)
|
|
174
|
+
* @param mailboxId - ID of the mailbox to create
|
|
175
|
+
* @param path - Path of the mailbox to create
|
|
176
|
+
* @param getConnection - Factory to get IMAP connection
|
|
177
|
+
* @param subscribe - Whether to subscribe after creation
|
|
178
|
+
*/
|
|
179
|
+
syncCreate = async (accountId, mailboxId, path, getConnection, subscribe) => {
|
|
180
|
+
const connection = await getConnection();
|
|
181
|
+
const result = await connection.createMailbox(path);
|
|
182
|
+
// The server is free to materialize the requested path under a namespace
|
|
183
|
+
// prefix — a Dovecot INBOX namespace turns "Notifications" into
|
|
184
|
+
// "INBOX/Notifications". `mailboxCreate` reports the canonical path the
|
|
185
|
+
// server assigned; adopt it as this row's identity so the next mailbox
|
|
186
|
+
// reconcile matches it by fullPath and updates it in place, rather than
|
|
187
|
+
// inserting a fresh row for the prefixed path and deleting this one — which
|
|
188
|
+
// would strand every filter and placement that references this mailboxId.
|
|
189
|
+
// Fall back to the requested path when the result carries no usable path,
|
|
190
|
+
// so a thin result never blanks the row's fullPath to undefined.
|
|
191
|
+
const serverPath = typeof result.path === "string" && result.path.length > 0
|
|
192
|
+
? result.path
|
|
193
|
+
: path;
|
|
194
|
+
const confirmedPath = serverPath !== path ? serverPath : undefined;
|
|
195
|
+
this.log.info({ mailboxId, path, serverPath, created: result.created }, "Created mailbox on IMAP server");
|
|
196
|
+
if (subscribe) {
|
|
197
|
+
await connection.subscribeMailbox(serverPath);
|
|
198
|
+
this.log.info({ mailboxId, path: serverPath }, "Subscribed to mailbox");
|
|
199
|
+
}
|
|
200
|
+
const holder = confirmedPath === undefined
|
|
201
|
+
? null
|
|
202
|
+
: await this.mailboxService.findByPath(accountId, confirmedPath);
|
|
203
|
+
if (holder && holder.mailboxId !== mailboxId) {
|
|
204
|
+
await this.mailboxService.deleteMailboxWithMail(accountId, mailboxId);
|
|
205
|
+
this.log.info({
|
|
206
|
+
accountId,
|
|
207
|
+
mailboxId,
|
|
208
|
+
intent: "create",
|
|
209
|
+
path,
|
|
210
|
+
serverPath,
|
|
211
|
+
existingMailboxId: holder.mailboxId,
|
|
212
|
+
outcome: "already-exists",
|
|
213
|
+
}, "Folder already exists at the path the server resolved");
|
|
214
|
+
return {
|
|
215
|
+
success: true,
|
|
216
|
+
existing: { mailboxId: holder.mailboxId, fullPath: holder.fullPath },
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
// Refresh mailbox list to get UIDVALIDITY and other attributes
|
|
220
|
+
const mailboxes = await connection.listMailboxes();
|
|
221
|
+
const mailboxInfo = mailboxes.find((m) => m.fullPath === serverPath);
|
|
222
|
+
if (mailboxInfo) {
|
|
223
|
+
// Open the mailbox to get UIDVALIDITY and other status info
|
|
224
|
+
const status = await connection.openBox(serverPath, true);
|
|
225
|
+
// Two writes where there was one, and the settle is deliberately the
|
|
226
|
+
// second: counters are ordinary metadata and stay off the transition,
|
|
227
|
+
// whose whole point is that its write-set is the state (D3). A crash in
|
|
228
|
+
// between leaves the row `pending` with fresh counters, and the row not
|
|
229
|
+
// yet settled is what keeps the queue message unacked — the redelivery
|
|
230
|
+
// re-issues CREATE, the server answers ALREADYEXISTS, and #339's
|
|
231
|
+
// classification settles it. Settling first would be the unsafe order.
|
|
232
|
+
await this.mailboxService.update(accountId, mailboxId, {
|
|
233
|
+
uidValidity: status.uidvalidity,
|
|
234
|
+
uidNext: status.uidnext,
|
|
235
|
+
messageCount: status.messages.total,
|
|
236
|
+
});
|
|
237
|
+
await this.settleCreate(accountId, mailboxId, confirmedPath);
|
|
238
|
+
await connection.closeBox();
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
// Mark as synced even if we couldn't get full info
|
|
242
|
+
await this.settleCreate(accountId, mailboxId, confirmedPath);
|
|
243
|
+
}
|
|
244
|
+
return { success: true };
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
247
|
+
* Sync a RENAME operation to IMAP and settle the rows that recorded it (T5).
|
|
248
|
+
* Called by worker after dequeuing MAILBOX_RENAME event.
|
|
249
|
+
*
|
|
250
|
+
* Guarded on the intent, not on the event (D10): the row must still be
|
|
251
|
+
* `pending` with this event's target recorded, or the rename resolves with no
|
|
252
|
+
* IMAP call at all. That is what stops a redelivered MAILBOX_RENAME re-issuing
|
|
253
|
+
* a RENAME from a path that has already moved.
|
|
254
|
+
*
|
|
255
|
+
* @param accountId - Account that owns the mailbox (tenant scope)
|
|
256
|
+
* @param mailboxId - ID of the mailbox to rename
|
|
257
|
+
* @param oldPath - Path the intent was recorded against
|
|
258
|
+
* @param newPath - Target the intent recorded
|
|
259
|
+
* @param getConnection - Factory to get IMAP connection
|
|
260
|
+
*/
|
|
261
|
+
syncRename = async (accountId, mailboxId, oldPath, newPath, getConnection) => {
|
|
262
|
+
const standing = await this.renameIntentStanding(accountId, mailboxId, newPath);
|
|
263
|
+
if (!standing)
|
|
264
|
+
return { success: true };
|
|
265
|
+
const connection = await getConnection();
|
|
266
|
+
const delimiter = standing.hierarchyDelimiter;
|
|
267
|
+
const confirmed = await this.issueRename(connection, standing, accountId, mailboxId, oldPath, newPath);
|
|
268
|
+
this.log.info({ accountId, mailboxId, intent: "rename", oldPath, newPath, confirmed }, "Renamed mailbox on IMAP server");
|
|
269
|
+
// The server has executed the rename. Nothing after this point may reach
|
|
270
|
+
// the caller's failure path: `failRename` would mark the rows refused for
|
|
271
|
+
// a rename that landed, and the folder would sit at `failed` offering a
|
|
272
|
+
// retry of something already done. A settle that cannot finish is wrapped
|
|
273
|
+
// so the caller can tell the two apart, and rethrown so SQS redelivers —
|
|
274
|
+
// the rows are still `pending` with their targets, so the redelivery's
|
|
275
|
+
// guard passes and the settle runs again.
|
|
276
|
+
await this.settleRenameIntent(accountId, mailboxId, oldPath, newPath, confirmed, delimiter).catch((error) => {
|
|
277
|
+
throw new FolderRenameSettleError(mailboxId, confirmed, error);
|
|
278
|
+
});
|
|
279
|
+
return {
|
|
280
|
+
success: true,
|
|
281
|
+
renamed: { oldPath, newPath: confirmed, delimiter },
|
|
282
|
+
};
|
|
283
|
+
};
|
|
284
|
+
/**
|
|
285
|
+
* Issue the RENAME and read back the path it resolved to.
|
|
286
|
+
*
|
|
287
|
+
* A `NONEXISTENT` here has three readings, and only one of them may destroy
|
|
288
|
+
* anything. Either another client deleted the folder; or this rename already
|
|
289
|
+
* landed and only its settle was lost, in which case the folder is alive at
|
|
290
|
+
* the target; or the server is saying something this code cannot classify.
|
|
291
|
+
* The listing separates the first two, and everything it cannot answer is a
|
|
292
|
+
* refused rename (T6) — a rename that failed costs the user a retry, while
|
|
293
|
+
* reading an unclear answer as a delete costs them the folder's mail.
|
|
294
|
+
*
|
|
295
|
+
* So the caller is told which of the two it is by the *kind* of error, never
|
|
296
|
+
* by re-reading the server's code: only `FolderGoneUpstreamError` removes a
|
|
297
|
+
* folder, and it is raised exactly where the listing held neither path.
|
|
298
|
+
*/
|
|
299
|
+
issueRename = async (connection, standing, accountId, mailboxId, oldPath, newPath) => {
|
|
300
|
+
const result = await connection
|
|
301
|
+
.renameMailbox(oldPath, newPath)
|
|
302
|
+
.catch(async (error) => {
|
|
303
|
+
if (!isMailboxAbsentUpstream(error))
|
|
304
|
+
throw error;
|
|
305
|
+
const listed = await connection
|
|
306
|
+
.listMailboxes()
|
|
307
|
+
.catch(() => undefined);
|
|
308
|
+
// A listing that cannot be read answers nothing, so it decides
|
|
309
|
+
// nothing: the rename is refused and the folder is left alone.
|
|
310
|
+
if (!listed)
|
|
311
|
+
throw error;
|
|
312
|
+
const onServer = new Set(listed.map((mailbox) => mailbox.fullPath));
|
|
313
|
+
if (holdsPath(onServer, standing, newPath)) {
|
|
314
|
+
this.log.info({ accountId, mailboxId, intent: "rename", oldPath, newPath }, "Source folder gone and the target is on the server: this rename already landed");
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
if (holdsPath(onServer, standing, oldPath))
|
|
318
|
+
throw error;
|
|
319
|
+
throw new FolderGoneUpstreamError(mailboxId, oldPath, error);
|
|
320
|
+
});
|
|
321
|
+
// ImapFlow's own normalization of the requested path — namespace prefix
|
|
322
|
+
// applied, special names resolved, delimiter joined. IMAP's RENAME reply
|
|
323
|
+
// carries no path, so this is not the server echoing a name back and it
|
|
324
|
+
// cannot detect a server that stored a different one; adopting it is still
|
|
325
|
+
// right, because it fixes the prefix and delimiter drift the requested
|
|
326
|
+
// string carries (D2).
|
|
327
|
+
return typeof result?.newPath === "string" && result.newPath.length > 0
|
|
328
|
+
? result.newPath
|
|
329
|
+
: newPath;
|
|
330
|
+
};
|
|
331
|
+
/**
|
|
332
|
+
* Whether the rename this event was enqueued for is still the folder's live
|
|
333
|
+
* intent. `pending` alone cannot answer it — that state covers a create in
|
|
334
|
+
* flight too — so the recorded target is half the predicate, and it is the
|
|
335
|
+
* same predicate the settling write carries, so the guard and the write
|
|
336
|
+
* cannot disagree between the read and the write.
|
|
337
|
+
*/
|
|
338
|
+
renameIntentStanding = async (accountId, mailboxId, newPath) => {
|
|
339
|
+
const row = await this.mailboxService
|
|
340
|
+
.get(accountId, mailboxId)
|
|
341
|
+
.catch((error) => {
|
|
342
|
+
if (isNotFoundError(error))
|
|
343
|
+
return undefined;
|
|
344
|
+
throw error;
|
|
345
|
+
});
|
|
346
|
+
if (row &&
|
|
347
|
+
row.syncStatus === MailboxSyncStatus.pending &&
|
|
348
|
+
row.pendingPath === newPath) {
|
|
349
|
+
return row;
|
|
350
|
+
}
|
|
351
|
+
this.log.info({
|
|
352
|
+
accountId,
|
|
353
|
+
mailboxId,
|
|
354
|
+
intent: "rename",
|
|
355
|
+
from: row?.syncStatus,
|
|
356
|
+
newPath,
|
|
357
|
+
outcome: row ? "superseded" : "already-settled",
|
|
358
|
+
}, "Skipping MAILBOX_RENAME: the recorded intent has moved on");
|
|
359
|
+
return undefined;
|
|
360
|
+
};
|
|
361
|
+
/**
|
|
362
|
+
* T5, per row (D15): each intent-carrying row adopts its own confirmed path,
|
|
363
|
+
* drops the recorded target and settles `synced`, on its own conditional
|
|
364
|
+
* write. A row another client moved in between fails its predicate and is
|
|
365
|
+
* skipped rather than overwritten; a row that is gone is skipped for free.
|
|
366
|
+
*
|
|
367
|
+
* **The named folder is written last, and that ordering is load-bearing.**
|
|
368
|
+
* The redelivery's guard reads that row and nothing else, so it is the
|
|
369
|
+
* witness for the whole settle: while it still carries the intent, a
|
|
370
|
+
* redelivery re-enters and finishes whatever the last attempt left. Settling
|
|
371
|
+
* it first would ack a run that died part-way through its descendants, and
|
|
372
|
+
* those rows would stay `pending` forever — invisible to message sync, and
|
|
373
|
+
* with no route out, because an intent may only be recorded from `synced` or
|
|
374
|
+
* `failed`, so neither a retry nor a dismissal can reach them.
|
|
375
|
+
*/
|
|
376
|
+
settleRenameIntent = async (accountId, mailboxId, oldPath, target, confirmed, delimiter) => {
|
|
377
|
+
const carrying = await this.intentCarryingRows(accountId, oldPath, target, confirmed, delimiter);
|
|
378
|
+
const ordered = [
|
|
379
|
+
...carrying.filter((entry) => entry.row.mailboxId !== mailboxId),
|
|
380
|
+
...carrying.filter((entry) => entry.row.mailboxId === mailboxId),
|
|
381
|
+
];
|
|
382
|
+
for (const { row, confirmedPath } of ordered) {
|
|
383
|
+
const settled = await this.mailboxService.transition(accountId, row.mailboxId, {
|
|
384
|
+
from: [MailboxSyncStatus.pending],
|
|
385
|
+
wherePendingPath: row.pendingPath,
|
|
386
|
+
to: MailboxSyncStatus.synced,
|
|
387
|
+
set: { fullPath: confirmedPath },
|
|
388
|
+
});
|
|
389
|
+
this.log.info({
|
|
390
|
+
accountId,
|
|
391
|
+
mailboxId: row.mailboxId,
|
|
392
|
+
intent: "rename",
|
|
393
|
+
from: MailboxSyncStatus.pending,
|
|
394
|
+
to: MailboxSyncStatus.synced,
|
|
395
|
+
fullPath: confirmedPath,
|
|
396
|
+
outcome: settled ? "settled" : "superseded",
|
|
397
|
+
}, "Settled renamed folder");
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
/**
|
|
401
|
+
* T6: the rename did not land, so every row that recorded it goes to
|
|
402
|
+
* `failed`, keeping its target so the client can name what the rename was
|
|
403
|
+
* aiming at and offer a retry. Nothing is restored — `fullPath` was never
|
|
404
|
+
* written, which is the whole point of recording the target instead (D2).
|
|
405
|
+
*/
|
|
406
|
+
failRename = async (accountId, mailboxId, oldPath, newPath, reason) => {
|
|
407
|
+
const root = await this.mailboxService
|
|
408
|
+
.get(accountId, mailboxId)
|
|
409
|
+
.catch((error) => {
|
|
410
|
+
if (isNotFoundError(error))
|
|
411
|
+
return undefined;
|
|
412
|
+
throw error;
|
|
413
|
+
});
|
|
414
|
+
if (!root)
|
|
415
|
+
throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
416
|
+
const carrying = await this.intentCarryingRows(accountId, oldPath, newPath, newPath, root.hierarchyDelimiter);
|
|
417
|
+
// The row is there and no row carries the intent, so this refusal has
|
|
418
|
+
// nothing to record. It must not be reported as a missing mailbox: the
|
|
419
|
+
// caller's #289-class guard reads a NotFoundError as the user having
|
|
420
|
+
// deleted the folder and acks the message, which would swallow the IMAP
|
|
421
|
+
// error that brought us here and leave the subtree `pending`. Say so and
|
|
422
|
+
// let the caller rethrow what actually failed.
|
|
423
|
+
if (carrying.length === 0) {
|
|
424
|
+
this.log.error({
|
|
425
|
+
accountId,
|
|
426
|
+
mailboxId,
|
|
427
|
+
intent: "rename",
|
|
428
|
+
from: root.syncStatus,
|
|
429
|
+
oldPath,
|
|
430
|
+
newPath,
|
|
431
|
+
outcome: "superseded",
|
|
432
|
+
}, "Refused rename recorded nothing: no row carries this intent");
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
for (const { row } of carrying) {
|
|
436
|
+
const failed = await this.mailboxService.transition(accountId, row.mailboxId, {
|
|
437
|
+
from: [MailboxSyncStatus.pending],
|
|
438
|
+
wherePendingPath: row.pendingPath,
|
|
439
|
+
to: MailboxSyncStatus.failed,
|
|
440
|
+
set: { syncFailureReason: reason },
|
|
441
|
+
});
|
|
442
|
+
this.log.info({
|
|
443
|
+
accountId,
|
|
444
|
+
mailboxId: row.mailboxId,
|
|
445
|
+
intent: "rename",
|
|
446
|
+
from: MailboxSyncStatus.pending,
|
|
447
|
+
to: MailboxSyncStatus.failed,
|
|
448
|
+
outcome: failed ? "refused" : "superseded",
|
|
449
|
+
}, "Recorded a refused rename");
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
/**
|
|
453
|
+
* The folder the rename was to move was found gone from the server, and
|
|
454
|
+
* the target was not there either, so it was deleted by another client
|
|
455
|
+
* mid-rename (D8). Every row that recorded this rename's intent — the
|
|
456
|
+
* root and each descendant — goes with its mail, the same walk `failRename`
|
|
457
|
+
* uses: deleting the root alone strands every descendant `pending` with
|
|
458
|
+
* nothing left to revisit it, which is the orphaning bug this closes.
|
|
459
|
+
*/
|
|
460
|
+
abandonRenameSubtree = async (accountId, mailboxId, oldPath, newPath) => {
|
|
461
|
+
const root = await this.mailboxService
|
|
462
|
+
.get(accountId, mailboxId)
|
|
463
|
+
.catch((error) => {
|
|
464
|
+
if (isNotFoundError(error))
|
|
465
|
+
return undefined;
|
|
466
|
+
throw error;
|
|
467
|
+
});
|
|
468
|
+
if (!root) {
|
|
469
|
+
this.log.info({ accountId, mailboxId, intent: "rename", outcome: "already-settled" }, "Nothing to abandon: the folder row is already gone");
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const carrying = await this.intentCarryingRows(accountId, oldPath, newPath, newPath, root.hierarchyDelimiter);
|
|
473
|
+
// A removal that throws part-way abandons the rest into a redelivery, and
|
|
474
|
+
// that is safe rather than merely tolerable: `deleteMailboxWithMail` is
|
|
475
|
+
// idempotent — it returns without touching anything when the row is gone,
|
|
476
|
+
// and resumes from whatever is left when it is not, because the mailbox
|
|
477
|
+
// row is removed last. The rows still standing carry the intent, so the
|
|
478
|
+
// next attempt resolves the same set and finishes it.
|
|
479
|
+
for (const { row } of carrying) {
|
|
480
|
+
await this.mailboxService.deleteMailboxWithMail(accountId, row.mailboxId);
|
|
481
|
+
this.log.info({
|
|
482
|
+
accountId,
|
|
483
|
+
mailboxId: row.mailboxId,
|
|
484
|
+
intent: "rename",
|
|
485
|
+
from: MailboxSyncStatus.pending,
|
|
486
|
+
to: "deleted",
|
|
487
|
+
}, "Source mailbox not found, deleted local folder and its mail");
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
/**
|
|
491
|
+
* Sync a DELETE operation to IMAP.
|
|
492
|
+
* Called by worker after dequeuing MAILBOX_DELETE event.
|
|
493
|
+
*
|
|
494
|
+
* @param accountId - Account that owns the mailbox (tenant scope)
|
|
495
|
+
* @param mailboxId - ID of the mailbox to delete
|
|
496
|
+
* @param path - Path of the mailbox to delete
|
|
497
|
+
* @param getConnection - Factory to get IMAP connection
|
|
498
|
+
*/
|
|
499
|
+
syncDelete = async (accountId, mailboxId, path, getConnection) => {
|
|
500
|
+
if (!(await this.deleteIntentStanding(accountId, mailboxId))) {
|
|
501
|
+
return { success: true };
|
|
502
|
+
}
|
|
503
|
+
const connection = await getConnection();
|
|
504
|
+
await connection.deleteMailbox(path);
|
|
505
|
+
this.log.info({ accountId, mailboxId, intent: "delete", path }, "Deleted mailbox on IMAP server");
|
|
506
|
+
await this.settleDelete(accountId, mailboxId);
|
|
507
|
+
return { success: true };
|
|
508
|
+
};
|
|
509
|
+
/**
|
|
510
|
+
* Whether the delete this event was enqueued for is still the folder's live
|
|
511
|
+
* intent (D10). Anything else resolves the job with no IMAP call — never a
|
|
512
|
+
* throw, which would hold back every later job in the account's FIFO group
|
|
513
|
+
* (#339).
|
|
514
|
+
*/
|
|
515
|
+
deleteIntentStanding = async (accountId, mailboxId) => {
|
|
516
|
+
const row = await this.mailboxService
|
|
517
|
+
.get(accountId, mailboxId)
|
|
518
|
+
.catch((error) => {
|
|
519
|
+
if (isNotFoundError(error))
|
|
520
|
+
return undefined;
|
|
521
|
+
throw error;
|
|
522
|
+
});
|
|
523
|
+
if (row?.syncStatus === MailboxSyncStatus.deleting)
|
|
524
|
+
return true;
|
|
525
|
+
this.log.info({
|
|
526
|
+
accountId,
|
|
527
|
+
mailboxId,
|
|
528
|
+
intent: "delete",
|
|
529
|
+
from: row?.syncStatus,
|
|
530
|
+
outcome: row ? "superseded" : "already-settled",
|
|
531
|
+
}, "Skipping MAILBOX_DELETE: the recorded intent has moved on");
|
|
532
|
+
return false;
|
|
533
|
+
};
|
|
534
|
+
/**
|
|
535
|
+
* T8: the confirmed delete takes the folder's local mail with it (D8).
|
|
536
|
+
* Removing the row alone leaves every `message` and `thread_message` row
|
|
537
|
+
* keyed to a dead mailboxId — out of every reader.
|
|
538
|
+
*
|
|
539
|
+
* The removal is batched and the mailbox row goes last, so the row stays
|
|
540
|
+
* `deleting` throughout and an SQS redelivery re-enters the handler, passes
|
|
541
|
+
* the guard above, and resumes where it stopped.
|
|
542
|
+
*/
|
|
543
|
+
settleDelete = async (accountId, mailboxId) => {
|
|
544
|
+
await this.mailboxService.deleteMailboxWithMail(accountId, mailboxId);
|
|
545
|
+
this.log.info({
|
|
546
|
+
accountId,
|
|
547
|
+
mailboxId,
|
|
548
|
+
intent: "delete",
|
|
549
|
+
from: MailboxSyncStatus.deleting,
|
|
550
|
+
outcome: "settled",
|
|
551
|
+
}, "Removed folder and its mail");
|
|
552
|
+
};
|
|
553
|
+
/**
|
|
554
|
+
* T9: the server refused the delete. Nothing is unwound — DELETE is
|
|
555
|
+
* all-or-nothing, so `fullPath` is still what the server holds, and no rename
|
|
556
|
+
* target is written, which is how the client tells a failed delete from a
|
|
557
|
+
* failed rename.
|
|
558
|
+
*/
|
|
559
|
+
failDelete = async (accountId, mailboxId, reason) => {
|
|
560
|
+
const failed = await this.mailboxService.transition(accountId, mailboxId, {
|
|
561
|
+
from: [MailboxSyncStatus.deleting],
|
|
562
|
+
to: MailboxSyncStatus.failed,
|
|
563
|
+
set: { syncFailureReason: reason },
|
|
564
|
+
});
|
|
565
|
+
if (!failed)
|
|
566
|
+
throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
567
|
+
this.log.info({
|
|
568
|
+
accountId,
|
|
569
|
+
mailboxId,
|
|
570
|
+
intent: "delete",
|
|
571
|
+
from: MailboxSyncStatus.deleting,
|
|
572
|
+
to: MailboxSyncStatus.failed,
|
|
573
|
+
outcome: "refused",
|
|
574
|
+
}, "Recorded a refused delete");
|
|
575
|
+
};
|
|
576
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
|
|
2
|
+
export declare const isNotFoundError: (error: unknown) => boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Every state a mailbox row can carry, for a transition whose caller decided
|
|
5
|
+
* against none of them.
|
|
6
|
+
*
|
|
7
|
+
* A transition predicate is supposed to name the state the caller read
|
|
8
|
+
* (docs/architecture/folder-rename-and-delete.md D3). What still uses this one
|
|
9
|
+
* is the delete path — its intent recorder and its settle are today's
|
|
10
|
+
* unconditional writes, moved onto the only door that can write a folder state
|
|
11
|
+
* at all, and #362 narrows each to the from-set its transition-table row
|
|
12
|
+
* allows, which is also where its loser gets a 409. The rename path is already
|
|
13
|
+
* narrowed: `INTENT_RECORDABLE_FROM` for the intent, and `pending` plus the
|
|
14
|
+
* recorded target for the settle.
|
|
15
|
+
*/
|
|
16
|
+
export declare const EVERY_MAILBOX_STATE: readonly ["synced", "pending", "failed", "deleting"];
|
|
17
|
+
/**
|
|
18
|
+
* Whether a folder mutation is in flight, so nothing else may touch the folder
|
|
19
|
+
* over IMAP.
|
|
20
|
+
*
|
|
21
|
+
* It used to be spelled as a presence test — is the folder off the server —
|
|
22
|
+
* and that is no longer what it asks. Under D2 `fullPath` is always a path the
|
|
23
|
+
* server holds, rename target and all, so a rename-pending folder *is* on the
|
|
24
|
+
* server; what the two states share is that a worker owns the folder until its
|
|
25
|
+
* mutation settles. `pending` and `deleting` are written by the request itself,
|
|
26
|
+
* before the event that resolves it is enqueued, so neither can be observed
|
|
27
|
+
* unless a create or rename has yet to land or a delete has been asked for.
|
|
28
|
+
*
|
|
29
|
+
* This is the terminal test for a sync event whose folder is not the sync's to
|
|
30
|
+
* touch. The row's absence alone does not decide it, at either end: on the way
|
|
31
|
+
* out the row is removed only after the IMAP folder is, so an event that fails
|
|
32
|
+
* against the server still sees a live row for as long as that write takes; on
|
|
33
|
+
* the way in the row exists before the folder does.
|
|
34
|
+
*
|
|
35
|
+
* `failed` is deliberately not one of them. The invariant is that a `failed`
|
|
36
|
+
* row's folder exists at `fullPath` and the last rename or delete intent did
|
|
37
|
+
* not land — so the folder holds the user's mail and syncing it is right.
|
|
38
|
+
* D7 is what upholds that: a create the server refuses leaves no row at all,
|
|
39
|
+
* rather than a `failed` one standing for a folder that was never made. Reading
|
|
40
|
+
* `failed` as off-server would stop syncing a live folder permanently and
|
|
41
|
+
* silently, which is worse than the bounded stall it would remove.
|
|
42
|
+
*
|
|
43
|
+
* A rename-pending folder is skipped even though its `fullPath` is live. That
|
|
44
|
+
* is conservative rather than necessary (D12): it keeps this a single
|
|
45
|
+
* expression and costs a few seconds of sync for one folder.
|
|
46
|
+
*
|
|
47
|
+
* Mailbox management is judged differently — it is what establishes and removes
|
|
48
|
+
* the folder, so it terminates on a not-found row alone.
|
|
49
|
+
*/
|
|
50
|
+
export declare const isFolderMutationInFlight: (mailbox: Pick<MailboxItem, "syncStatus">) => boolean;
|
|
51
|
+
/** {@link isFolderMutationInFlight} for a mailbox that has to be read first, an absent row included. */
|
|
52
|
+
export declare const isMailboxMutationInFlight: (mailboxService: Pick<IMailboxRepository, "get">, accountId: string, mailboxId: string) => Promise<boolean>;
|