@remit/imap-worker 0.0.90 → 0.0.91
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-check.d.ts +27 -0
- package/dist/account-check.js +70 -0
- package/dist/body-sync-gate.d.ts +12 -0
- package/dist/body-sync-gate.js +53 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +193 -0
- package/dist/config-env.d.ts +5 -0
- package/dist/config-env.js +37 -0
- package/dist/connection-pool.d.ts +22 -0
- package/dist/connection-pool.js +97 -0
- package/dist/connection-scope.d.ts +8 -0
- package/dist/connection-scope.js +10 -0
- package/dist/context.d.ts +46 -0
- package/dist/context.js +15 -0
- package/dist/e2e-processor-shim.d.ts +2 -0
- package/dist/e2e-processor-shim.js +181 -0
- package/dist/emit.d.ts +15 -0
- package/dist/emit.js +76 -0
- package/dist/events.d.ts +203 -0
- package/dist/events.js +1 -0
- package/dist/failure-kind.d.ts +10 -0
- package/{src/failure-kind.ts → dist/failure-kind.js} +6 -6
- package/dist/handlers/append-sent-message.d.ts +3 -0
- package/dist/handlers/append-sent-message.js +152 -0
- package/dist/handlers/delete-account-objects.d.ts +3 -0
- package/dist/handlers/delete-account-objects.js +35 -0
- package/dist/handlers/empty-trash.d.ts +14 -0
- package/dist/handlers/empty-trash.js +217 -0
- package/dist/handlers/flag-push.d.ts +38 -0
- package/dist/handlers/flag-push.js +195 -0
- package/dist/handlers/imported-folder.d.ts +4 -0
- package/dist/handlers/imported-folder.js +23 -0
- package/dist/handlers/mailbox-management.d.ts +6 -0
- package/dist/handlers/mailbox-management.js +294 -0
- package/dist/handlers/mailbox-sync-order.d.ts +21 -0
- package/dist/handlers/mailbox-sync-order.js +50 -0
- package/dist/handlers/message-copy.d.ts +40 -0
- package/dist/handlers/message-copy.js +371 -0
- package/dist/handlers/message-delete-terminal.d.ts +60 -0
- package/dist/handlers/message-delete-terminal.js +77 -0
- package/dist/handlers/message-delete.d.ts +57 -0
- package/dist/handlers/message-delete.js +520 -0
- package/{src/handlers/message-move-terminal.ts → dist/handlers/message-move-terminal.d.ts} +18 -105
- package/dist/handlers/message-move-terminal.js +94 -0
- package/dist/handlers/message-move.d.ts +146 -0
- package/dist/handlers/message-move.js +490 -0
- package/dist/handlers/placement-move-push.d.ts +62 -0
- package/dist/handlers/placement-move-push.js +307 -0
- package/dist/handlers/probe-message-id.d.ts +2 -0
- package/dist/handlers/probe-message-id.js +6 -0
- package/dist/handlers/sync-mailboxes.d.ts +45 -0
- package/dist/handlers/sync-mailboxes.js +233 -0
- package/dist/handlers/sync-message-body.d.ts +23 -0
- package/dist/handlers/sync-message-body.js +182 -0
- package/dist/handlers/sync-messages.d.ts +29 -0
- package/dist/handlers/sync-messages.js +285 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +67 -0
- package/dist/lambda-context.d.ts +8 -0
- package/dist/lambda-context.js +33 -0
- package/dist/mailbox-row.d.ts +9 -0
- package/dist/mailbox-row.js +6 -0
- package/dist/poller.d.ts +1 -0
- package/dist/poller.js +51 -0
- package/dist/process.d.ts +6 -0
- package/dist/process.js +3 -0
- package/dist/processor.d.ts +3 -0
- package/dist/processor.js +44 -0
- package/dist/queues.d.ts +10 -0
- package/dist/queues.js +1 -0
- package/dist/s3-account-objects.d.ts +3 -0
- package/dist/s3-account-objects.js +26 -0
- package/dist/scheduler/attachment-sweep.d.ts +11 -0
- package/dist/scheduler/attachment-sweep.js +23 -0
- package/dist/scheduler/calendar-subscriptions.d.ts +26 -0
- package/dist/scheduler/calendar-subscriptions.js +35 -0
- package/dist/scheduler/config.d.ts +27 -0
- package/{src/scheduler/config.ts → dist/scheduler/config.js} +7 -26
- package/{src/scheduler/decide-due.ts → dist/scheduler/decide-due.d.ts} +3 -12
- package/dist/scheduler/decide-due.js +17 -0
- package/dist/scheduler/handler.d.ts +21 -0
- package/{src/scheduler/handler.ts → dist/scheduler/handler.js} +25 -42
- package/{src/scheduler/loop.ts → dist/scheduler/loop.d.ts} +7 -23
- package/dist/scheduler/loop.js +8 -0
- package/dist/scheduler/run-tick.d.ts +62 -0
- package/dist/scheduler/run-tick.js +164 -0
- package/dist/scheduler/runner.d.ts +2 -0
- package/{src/scheduler/runner.ts → dist/scheduler/runner.js} +57 -78
- package/dist/scheduler/stranded-placement-redrive.d.ts +16 -0
- package/dist/scheduler/stranded-placement-redrive.js +172 -0
- package/dist/sqs-queues.d.ts +4 -0
- package/dist/sqs-queues.js +22 -0
- package/dist/with-oauth-lifecycle-deps.d.ts +5 -0
- package/dist/with-oauth-lifecycle-deps.js +17 -0
- package/dist/with-oauth-lifecycle.d.ts +56 -0
- package/dist/with-oauth-lifecycle.js +92 -0
- package/{src/worker-version.ts → dist/worker-version.d.ts} +1 -2
- package/dist/worker-version.js +12 -0
- package/package.json +75 -69
- package/src/account-check.test.ts +0 -149
- package/src/account-check.ts +0 -101
- package/src/body-sync-gate.test.ts +0 -185
- package/src/body-sync-gate.ts +0 -86
- package/src/cli.ts +0 -218
- package/src/connection-scope.test.ts +0 -266
- package/src/connection-scope.ts +0 -314
- package/src/credential-less-sync-fence.test.ts +0 -152
- package/src/e2e-processor-shim.ts +0 -232
- package/src/emit.test.ts +0 -145
- package/src/emit.ts +0 -115
- package/src/events.ts +0 -252
- package/src/failure-kind.test.ts +0 -42
- package/src/handlers/append-sent-message.sqlite.test.ts +0 -176
- package/src/handlers/append-sent-message.ts +0 -264
- package/src/handlers/delete-account-objects.test.ts +0 -126
- package/src/handlers/delete-account-objects.ts +0 -96
- package/src/handlers/empty-trash.sqlite.test.ts +0 -209
- package/src/handlers/empty-trash.ts +0 -352
- package/src/handlers/flag-push.ts +0 -285
- package/src/handlers/imported-folder.test.ts +0 -63
- package/src/handlers/imported-folder.ts +0 -38
- package/src/handlers/mailbox-management.sqlite.test.ts +0 -265
- package/src/handlers/mailbox-management.ts +0 -486
- package/src/handlers/mailbox-sync-order.test.ts +0 -93
- package/src/handlers/mailbox-sync-order.ts +0 -65
- package/src/handlers/message-copy.sqlite.test.ts +0 -298
- package/src/handlers/message-copy.ts +0 -569
- package/src/handlers/message-delete-terminal.ts +0 -147
- package/src/handlers/message-delete.sqlite.test.ts +0 -291
- package/src/handlers/message-delete.ts +0 -888
- package/src/handlers/message-move.sqlite.test.ts +0 -271
- package/src/handlers/message-move.test.ts +0 -216
- package/src/handlers/message-move.ts +0 -755
- package/src/handlers/placement-move-push.test.ts +0 -137
- package/src/handlers/placement-move-push.ts +0 -478
- package/src/handlers/placement-push.sqlite.test.ts +0 -299
- package/src/handlers/probe-message-id.sqlite.test.ts +0 -192
- package/src/handlers/probe-message-id.ts +0 -15
- package/src/handlers/sync-mailboxes.test.ts +0 -183
- package/src/handlers/sync-mailboxes.ts +0 -425
- package/src/handlers/sync-message-body.sqlite.test.ts +0 -314
- package/src/handlers/sync-message-body.test.ts +0 -150
- package/src/handlers/sync-message-body.ts +0 -366
- package/src/handlers/sync-messages.sqlite.test.ts +0 -195
- package/src/handlers/sync-messages.ts +0 -537
- package/src/index.test.ts +0 -22
- package/src/index.ts +0 -82
- package/src/mail-sync-selection.sqlite.test.ts +0 -227
- package/src/mailbox-row.ts +0 -20
- package/src/pending-sync.sqlite.test.ts +0 -165
- package/src/poller.ts +0 -55
- package/src/processor.ts +0 -62
- package/src/queues.ts +0 -40
- package/src/scheduler/attachment-sweep.sqlite.test.ts +0 -74
- package/src/scheduler/attachment-sweep.ts +0 -46
- package/src/scheduler/calendar-subscriptions.sqlite.test.ts +0 -147
- package/src/scheduler/calendar-subscriptions.ts +0 -103
- package/src/scheduler/config.test.ts +0 -40
- package/src/scheduler/decide-due.test.ts +0 -44
- package/src/scheduler/half-deleted-accounts.sqlite.test.ts +0 -196
- package/src/scheduler/loop.test.ts +0 -143
- package/src/scheduler/run-tick.sqlite.test.ts +0 -191
- package/src/scheduler/run-tick.ts +0 -281
- package/src/scheduler/stranded-placement-redrive.ts +0 -265
- package/src/stranded-placement.sqlite.test.ts +0 -799
- package/src/test-helpers/test-world.ts +0 -425
- package/src/test-helpers/worker-queues.ts +0 -11
- package/src/thread-join.sqlite.test.ts +0 -106
- package/src/with-oauth-lifecycle-deps.sqlite.test.ts +0 -269
- package/src/with-oauth-lifecycle-deps.ts +0 -71
- package/src/with-oauth-lifecycle.ts +0 -158
- package/tsconfig.json +0 -8
|
@@ -1,486 +0,0 @@
|
|
|
1
|
-
import { getClient } from "@remit/backend/client";
|
|
2
|
-
import { refreshFolderAppointmentLabels } from "@remit/backend/folder-role-labels";
|
|
3
|
-
import type { IMailboxRepository } from "@remit/data-ports";
|
|
4
|
-
import { isNotFoundError, NotFoundError } from "@remit/data-ports/errors";
|
|
5
|
-
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
6
|
-
import type { Logger } from "@remit/logger";
|
|
7
|
-
import {
|
|
8
|
-
FolderGoneUpstreamError,
|
|
9
|
-
FolderRenameSettleError,
|
|
10
|
-
isMailboxAbsentUpstream,
|
|
11
|
-
isMailboxPresentUpstream,
|
|
12
|
-
isMailboxRefusedUpstream,
|
|
13
|
-
MailboxManagementService,
|
|
14
|
-
upstreamFailureReason,
|
|
15
|
-
} from "@remit/mailbox-service";
|
|
16
|
-
import { isAccountDeleted } from "../account-check.js";
|
|
17
|
-
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
18
|
-
import type {
|
|
19
|
-
MailboxCreateEvent,
|
|
20
|
-
MailboxDeleteEvent,
|
|
21
|
-
MailboxManagementEvent,
|
|
22
|
-
MailboxRenameEvent,
|
|
23
|
-
} from "../events.js";
|
|
24
|
-
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
25
|
-
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* The outcome of a create, written against the state a create in flight is:
|
|
29
|
-
* `pending` with no recorded rename target (D3, D10). Without the absence
|
|
30
|
-
* check, a create redelivered after a lost acknowledgement settles a row a
|
|
31
|
-
* rename has since claimed and kills the rename silently.
|
|
32
|
-
*/
|
|
33
|
-
const recordCreateOutcome = async (
|
|
34
|
-
mailboxService: Pick<IMailboxRepository, "transition">,
|
|
35
|
-
accountId: string,
|
|
36
|
-
mailboxId: string,
|
|
37
|
-
to: (typeof MailboxSyncStatus)[keyof typeof MailboxSyncStatus],
|
|
38
|
-
syncFailureReason = "",
|
|
39
|
-
): Promise<void> => {
|
|
40
|
-
const written = await mailboxService.transition(accountId, mailboxId, {
|
|
41
|
-
from: [MailboxSyncStatus.pending],
|
|
42
|
-
wherePendingPath: null,
|
|
43
|
-
to,
|
|
44
|
-
set: { syncFailureReason },
|
|
45
|
-
});
|
|
46
|
-
if (!written) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Pinned invariant for the whole-chain terminal guards below.
|
|
51
|
-
*
|
|
52
|
-
* Each handler wraps its `MailboxManagementService.sync*` chain in a try/catch
|
|
53
|
-
* that treats a NotFoundError as terminal (ack with a WARN, issue #289). That is
|
|
54
|
-
* only sound while the sole row a NotFoundError can refer to is the target
|
|
55
|
-
* mailbox, so its absence can only mean the user deleted the folder mid-sync —
|
|
56
|
-
* never an unrelated missing entity that should have been retried.
|
|
57
|
-
*
|
|
58
|
-
* `syncCreate` and `syncDelete` hold to that by touching nothing but the target
|
|
59
|
-
* row. `syncRename` also writes every other row that recorded the same intent,
|
|
60
|
-
* and keeps the invariant by treating a lost predicate as an outcome rather than
|
|
61
|
-
* an error: a descendant deleted mid-settle is skipped, never raised. The
|
|
62
|
-
* appointment-label refresh writes `account_setting` rows, whose reads answer
|
|
63
|
-
* absent rather than throwing. Any sync method that reads or writes a second
|
|
64
|
-
* entity owes the same, or these catches must be narrowed (match the mailboxId /
|
|
65
|
-
* re-check existence) before a NotFoundError from elsewhere is silently acked.
|
|
66
|
-
*/
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Handle MAILBOX_CREATE event
|
|
70
|
-
*/
|
|
71
|
-
const handleCreate = async (
|
|
72
|
-
event: MailboxCreateEvent,
|
|
73
|
-
log: Logger,
|
|
74
|
-
createConnectionScope: typeof createConnectionScopeWithCredentials,
|
|
75
|
-
): Promise<void> => {
|
|
76
|
-
const {
|
|
77
|
-
account: accountService,
|
|
78
|
-
mailbox: mailboxService,
|
|
79
|
-
secrets,
|
|
80
|
-
} = await getClient();
|
|
81
|
-
|
|
82
|
-
const { accountId, mailboxId, path, subscribe } = event;
|
|
83
|
-
|
|
84
|
-
log.info({ event: event.type, accountId, mailboxId, path }, "Handling event");
|
|
85
|
-
|
|
86
|
-
const account = await accountService.get(accountId);
|
|
87
|
-
if (!account) {
|
|
88
|
-
throw new Error(`Account ${accountId} not found`);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (isAccountDeleted(account, log)) {
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
await withOAuthLifecycle(
|
|
96
|
-
buildLifecycleDeps(secrets, accountService),
|
|
97
|
-
account,
|
|
98
|
-
log,
|
|
99
|
-
async (credentials) => {
|
|
100
|
-
const scope = createConnectionScope(account, credentials);
|
|
101
|
-
const managementService = new MailboxManagementService(
|
|
102
|
-
mailboxService,
|
|
103
|
-
log,
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
try {
|
|
107
|
-
await managementService
|
|
108
|
-
.syncCreate(
|
|
109
|
-
accountId,
|
|
110
|
-
mailboxId,
|
|
111
|
-
path,
|
|
112
|
-
scope.getConnection,
|
|
113
|
-
subscribe,
|
|
114
|
-
)
|
|
115
|
-
.then((result) => {
|
|
116
|
-
if (result.success) {
|
|
117
|
-
log.info(
|
|
118
|
-
{
|
|
119
|
-
accountId,
|
|
120
|
-
mailboxId,
|
|
121
|
-
path,
|
|
122
|
-
existingMailboxId: result.existing?.mailboxId,
|
|
123
|
-
},
|
|
124
|
-
"Mailbox created on IMAP",
|
|
125
|
-
);
|
|
126
|
-
} else {
|
|
127
|
-
log.error(
|
|
128
|
-
{ accountId, mailboxId, path, error: result.error },
|
|
129
|
-
"Failed to create mailbox on IMAP",
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
})
|
|
133
|
-
.catch(async (error) => {
|
|
134
|
-
// Check if mailbox already exists (idempotent)
|
|
135
|
-
if (isMailboxPresentUpstream(error)) {
|
|
136
|
-
log.info(
|
|
137
|
-
{ accountId, mailboxId, path },
|
|
138
|
-
"Mailbox already exists, marking as synced",
|
|
139
|
-
);
|
|
140
|
-
await recordCreateOutcome(
|
|
141
|
-
mailboxService,
|
|
142
|
-
accountId,
|
|
143
|
-
mailboxId,
|
|
144
|
-
MailboxSyncStatus.synced,
|
|
145
|
-
);
|
|
146
|
-
} else {
|
|
147
|
-
await recordCreateOutcome(
|
|
148
|
-
mailboxService,
|
|
149
|
-
accountId,
|
|
150
|
-
mailboxId,
|
|
151
|
-
MailboxSyncStatus.failed,
|
|
152
|
-
upstreamFailureReason(error),
|
|
153
|
-
);
|
|
154
|
-
if (isMailboxRefusedUpstream(error)) {
|
|
155
|
-
log.error(
|
|
156
|
-
{ accountId, mailboxId, path, intent: "create", error },
|
|
157
|
-
"Mail server refused the create",
|
|
158
|
-
);
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
throw error;
|
|
162
|
-
}
|
|
163
|
-
})
|
|
164
|
-
.finally(() => scope.disconnect());
|
|
165
|
-
} catch (error) {
|
|
166
|
-
// The mailbox row was deleted between enqueue and this sync — the
|
|
167
|
-
// create-then-status-write throws NotFoundError, which can never
|
|
168
|
-
// succeed on retry and would poison the account's per-group FIFO
|
|
169
|
-
// forever (issue #289, same class as #287/#290). A folder the user
|
|
170
|
-
// deleted is an expected terminal outcome: ack with a WARN. Real
|
|
171
|
-
// IMAP/infra failures carry other errors and still propagate.
|
|
172
|
-
if (isNotFoundError(error)) {
|
|
173
|
-
log.warn(
|
|
174
|
-
{ accountId, mailboxId, path, eventId: event.eventId },
|
|
175
|
-
"Skipping MAILBOX_CREATE: mailbox no longer exists (deleted)",
|
|
176
|
-
);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
throw error;
|
|
180
|
-
}
|
|
181
|
-
},
|
|
182
|
-
);
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Handle MAILBOX_RENAME event
|
|
187
|
-
*/
|
|
188
|
-
const handleRename = async (
|
|
189
|
-
event: MailboxRenameEvent,
|
|
190
|
-
log: Logger,
|
|
191
|
-
createConnectionScope: typeof createConnectionScopeWithCredentials,
|
|
192
|
-
): Promise<void> => {
|
|
193
|
-
const {
|
|
194
|
-
account: accountService,
|
|
195
|
-
accountSetting: accountSettingService,
|
|
196
|
-
mailbox: mailboxService,
|
|
197
|
-
secrets,
|
|
198
|
-
} = await getClient();
|
|
199
|
-
|
|
200
|
-
const { accountId, mailboxId, oldPath, newPath } = event;
|
|
201
|
-
|
|
202
|
-
log.info(
|
|
203
|
-
{ event: event.type, accountId, mailboxId, oldPath, newPath },
|
|
204
|
-
"Handling event",
|
|
205
|
-
);
|
|
206
|
-
|
|
207
|
-
const account = await accountService.get(accountId);
|
|
208
|
-
if (!account) {
|
|
209
|
-
throw new Error(`Account ${accountId} not found`);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (isAccountDeleted(account, log)) {
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
await withOAuthLifecycle(
|
|
217
|
-
buildLifecycleDeps(secrets, accountService),
|
|
218
|
-
account,
|
|
219
|
-
log,
|
|
220
|
-
async (credentials) => {
|
|
221
|
-
const scope = createConnectionScope(account, credentials);
|
|
222
|
-
const managementService = new MailboxManagementService(
|
|
223
|
-
mailboxService,
|
|
224
|
-
log,
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
try {
|
|
228
|
-
await managementService
|
|
229
|
-
.syncRename(
|
|
230
|
-
accountId,
|
|
231
|
-
mailboxId,
|
|
232
|
-
oldPath,
|
|
233
|
-
newPath,
|
|
234
|
-
scope.getConnection,
|
|
235
|
-
)
|
|
236
|
-
.then(async (result) => {
|
|
237
|
-
if (!result.success) {
|
|
238
|
-
log.error(
|
|
239
|
-
{ accountId, mailboxId, oldPath, newPath, error: result.error },
|
|
240
|
-
"Failed to rename mailbox on IMAP",
|
|
241
|
-
);
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
log.info(
|
|
245
|
-
{ accountId, mailboxId, oldPath, newPath },
|
|
246
|
-
"Mailbox renamed on IMAP",
|
|
247
|
-
);
|
|
248
|
-
if (!result.renamed) return;
|
|
249
|
-
// The path recorded beside each role appointment (#887) moves with
|
|
250
|
-
// the settle, because under D2 that is the first moment the new
|
|
251
|
-
// path is one the server holds.
|
|
252
|
-
//
|
|
253
|
-
// A label is display-only and this runs after the server rename
|
|
254
|
-
// landed, so letting it fail would take the job down the failure
|
|
255
|
-
// path and mark a completed rename refused. Logged and dropped;
|
|
256
|
-
// the next appointment write records the folder's current path
|
|
257
|
-
// anyway.
|
|
258
|
-
await refreshFolderAppointmentLabels(
|
|
259
|
-
accountSettingService,
|
|
260
|
-
account.accountConfigId,
|
|
261
|
-
accountId,
|
|
262
|
-
{
|
|
263
|
-
mailboxId,
|
|
264
|
-
oldPath: result.renamed.oldPath,
|
|
265
|
-
newPath: result.renamed.newPath,
|
|
266
|
-
},
|
|
267
|
-
result.renamed.delimiter,
|
|
268
|
-
).catch((error: unknown) => {
|
|
269
|
-
log.error(
|
|
270
|
-
{ accountId, mailboxId, oldPath, newPath, error },
|
|
271
|
-
"Renamed folder settled, but its role-appointment labels did not move",
|
|
272
|
-
);
|
|
273
|
-
});
|
|
274
|
-
})
|
|
275
|
-
.catch(async (error) => {
|
|
276
|
-
// The server executed the rename and only the local settle did not
|
|
277
|
-
// finish. That is not a refused rename: marking the rows `failed`
|
|
278
|
-
// would offer a retry of work already done and leave `fullPath`
|
|
279
|
-
// naming a path the server no longer holds. Rethrow, so the
|
|
280
|
-
// redelivery finishes the settle against an intent still standing.
|
|
281
|
-
if (error instanceof FolderRenameSettleError) {
|
|
282
|
-
log.error(
|
|
283
|
-
{ accountId, mailboxId, oldPath, newPath, error },
|
|
284
|
-
"Rename landed on the server; the settle will finish on redelivery",
|
|
285
|
-
);
|
|
286
|
-
throw error;
|
|
287
|
-
}
|
|
288
|
-
// The folder the rename was to move is confirmed gone: the
|
|
289
|
-
// server refused the RENAME as non-existent and its own listing
|
|
290
|
-
// held neither path. The rename decides that, once, where the
|
|
291
|
-
// evidence is — re-reading the response code here would make
|
|
292
|
-
// every `NONEXISTENT` this code cannot classify destroy the
|
|
293
|
-
// folder's mail. Removing the root row alone is the orphaning
|
|
294
|
-
// bug: every descendant recorded the same intent (D6) and would
|
|
295
|
-
// be left `pending` at a path that does not exist, with its mail
|
|
296
|
-
// stranded out of every reader (D8).
|
|
297
|
-
if (error instanceof FolderGoneUpstreamError) {
|
|
298
|
-
log.info(
|
|
299
|
-
{ accountId, mailboxId, oldPath, intent: "rename" },
|
|
300
|
-
"Source mailbox not found, deleting local folder and its mail",
|
|
301
|
-
);
|
|
302
|
-
await managementService.abandonRenameSubtree(
|
|
303
|
-
accountId,
|
|
304
|
-
mailboxId,
|
|
305
|
-
oldPath,
|
|
306
|
-
newPath,
|
|
307
|
-
);
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
// T6. Nothing is restored — `fullPath` was never written, and
|
|
311
|
-
// each intent-carrying row keeps its target so the client can
|
|
312
|
-
// name what the rename was aiming at and offer a retry.
|
|
313
|
-
await managementService.failRename(
|
|
314
|
-
accountId,
|
|
315
|
-
mailboxId,
|
|
316
|
-
oldPath,
|
|
317
|
-
newPath,
|
|
318
|
-
upstreamFailureReason(error),
|
|
319
|
-
);
|
|
320
|
-
if (isMailboxRefusedUpstream(error)) {
|
|
321
|
-
log.error(
|
|
322
|
-
{
|
|
323
|
-
accountId,
|
|
324
|
-
mailboxId,
|
|
325
|
-
oldPath,
|
|
326
|
-
newPath,
|
|
327
|
-
intent: "rename",
|
|
328
|
-
error,
|
|
329
|
-
},
|
|
330
|
-
"Mail server refused the rename",
|
|
331
|
-
);
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
|
-
throw error;
|
|
335
|
-
})
|
|
336
|
-
.finally(() => scope.disconnect());
|
|
337
|
-
} catch (error) {
|
|
338
|
-
// The mailbox row was deleted between enqueue and this sync — the
|
|
339
|
-
// status write-back throws NotFoundError, unretryable and would poison
|
|
340
|
-
// the account's per-group FIFO forever (issue #289 class). Ack with a
|
|
341
|
-
// WARN; real IMAP/infra failures carry other errors and still propagate.
|
|
342
|
-
if (isNotFoundError(error)) {
|
|
343
|
-
log.warn(
|
|
344
|
-
{ accountId, mailboxId, oldPath, newPath, eventId: event.eventId },
|
|
345
|
-
"Skipping MAILBOX_RENAME: mailbox no longer exists (deleted)",
|
|
346
|
-
);
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
|
-
throw error;
|
|
350
|
-
}
|
|
351
|
-
},
|
|
352
|
-
);
|
|
353
|
-
};
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Handle MAILBOX_DELETE event
|
|
357
|
-
*/
|
|
358
|
-
const handleDelete = async (
|
|
359
|
-
event: MailboxDeleteEvent,
|
|
360
|
-
log: Logger,
|
|
361
|
-
createConnectionScope: typeof createConnectionScopeWithCredentials,
|
|
362
|
-
): Promise<void> => {
|
|
363
|
-
const {
|
|
364
|
-
account: accountService,
|
|
365
|
-
mailbox: mailboxService,
|
|
366
|
-
secrets,
|
|
367
|
-
} = await getClient();
|
|
368
|
-
|
|
369
|
-
const { accountId, mailboxId, path } = event;
|
|
370
|
-
|
|
371
|
-
log.info({ event: event.type, accountId, mailboxId, path }, "Handling event");
|
|
372
|
-
|
|
373
|
-
const account = await accountService.get(accountId);
|
|
374
|
-
if (!account) {
|
|
375
|
-
throw new Error(`Account ${accountId} not found`);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
if (isAccountDeleted(account, log)) {
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
await withOAuthLifecycle(
|
|
383
|
-
buildLifecycleDeps(secrets, accountService),
|
|
384
|
-
account,
|
|
385
|
-
log,
|
|
386
|
-
async (credentials) => {
|
|
387
|
-
const scope = createConnectionScope(account, credentials);
|
|
388
|
-
const managementService = new MailboxManagementService(
|
|
389
|
-
mailboxService,
|
|
390
|
-
log,
|
|
391
|
-
);
|
|
392
|
-
|
|
393
|
-
try {
|
|
394
|
-
await managementService
|
|
395
|
-
.syncDelete(accountId, mailboxId, path, scope.getConnection)
|
|
396
|
-
.then((result) => {
|
|
397
|
-
if (result.success) {
|
|
398
|
-
log.info(
|
|
399
|
-
{ accountId, mailboxId, path },
|
|
400
|
-
"Mailbox deleted on IMAP",
|
|
401
|
-
);
|
|
402
|
-
} else {
|
|
403
|
-
log.error(
|
|
404
|
-
{ accountId, mailboxId, path, error: result.error },
|
|
405
|
-
"Failed to delete mailbox on IMAP",
|
|
406
|
-
);
|
|
407
|
-
}
|
|
408
|
-
})
|
|
409
|
-
.catch(async (error) => {
|
|
410
|
-
// The folder is already gone from the server, so the delete has
|
|
411
|
-
// happened. Its mail still goes with it (D8), which is the whole
|
|
412
|
-
// point of the settle.
|
|
413
|
-
if (isMailboxAbsentUpstream(error)) {
|
|
414
|
-
log.info(
|
|
415
|
-
{ accountId, mailboxId, path, intent: "delete" },
|
|
416
|
-
"Mailbox not found on IMAP, removing the local folder and its mail",
|
|
417
|
-
);
|
|
418
|
-
await managementService.settleDelete(accountId, mailboxId);
|
|
419
|
-
} else {
|
|
420
|
-
// T9, INBOX included. The API refuses a delete of INBOX, so the
|
|
421
|
-
// worker's backstop is unreachable from it; if something else
|
|
422
|
-
// reaches it, the honest outcome is `failed`, not `synced` —
|
|
423
|
-
// the folder was never deleted and nothing was undone.
|
|
424
|
-
await managementService.failDelete(
|
|
425
|
-
accountId,
|
|
426
|
-
mailboxId,
|
|
427
|
-
upstreamFailureReason(error),
|
|
428
|
-
);
|
|
429
|
-
if (
|
|
430
|
-
error instanceof Error &&
|
|
431
|
-
error.message.includes("Cannot delete INBOX")
|
|
432
|
-
) {
|
|
433
|
-
log.error(
|
|
434
|
-
{ accountId, mailboxId, path, intent: "delete" },
|
|
435
|
-
"Cannot delete INBOX",
|
|
436
|
-
);
|
|
437
|
-
// Don't rethrow — no retry can make this succeed.
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
if (isMailboxRefusedUpstream(error)) {
|
|
441
|
-
log.error(
|
|
442
|
-
{ accountId, mailboxId, path, intent: "delete", error },
|
|
443
|
-
"Mail server refused the delete",
|
|
444
|
-
);
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
throw error;
|
|
448
|
-
}
|
|
449
|
-
})
|
|
450
|
-
.finally(() => scope.disconnect());
|
|
451
|
-
} catch (error) {
|
|
452
|
-
// The mailbox row was already deleted (a duplicate/racing delete) — the
|
|
453
|
-
// error-recovery status write throws NotFoundError, unretryable and
|
|
454
|
-
// would poison the account's per-group FIFO forever (issue #289 class).
|
|
455
|
-
// The delete has effectively happened: ack with a WARN. Real IMAP/infra
|
|
456
|
-
// failures carry other errors and still propagate.
|
|
457
|
-
if (isNotFoundError(error)) {
|
|
458
|
-
log.warn(
|
|
459
|
-
{ accountId, mailboxId, path, eventId: event.eventId },
|
|
460
|
-
"Skipping MAILBOX_DELETE: mailbox no longer exists (deleted)",
|
|
461
|
-
);
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
throw error;
|
|
465
|
-
}
|
|
466
|
-
},
|
|
467
|
-
);
|
|
468
|
-
};
|
|
469
|
-
|
|
470
|
-
/**
|
|
471
|
-
* Process mailbox management events
|
|
472
|
-
*/
|
|
473
|
-
export const processMailboxManagement = async (
|
|
474
|
-
event: MailboxManagementEvent,
|
|
475
|
-
log: Logger,
|
|
476
|
-
createConnectionScope = createConnectionScopeWithCredentials,
|
|
477
|
-
): Promise<void> => {
|
|
478
|
-
switch (event.type) {
|
|
479
|
-
case "MAILBOX_CREATE":
|
|
480
|
-
return handleCreate(event, log, createConnectionScope);
|
|
481
|
-
case "MAILBOX_RENAME":
|
|
482
|
-
return handleRename(event, log, createConnectionScope);
|
|
483
|
-
case "MAILBOX_DELETE":
|
|
484
|
-
return handleDelete(event, log, createConnectionScope);
|
|
485
|
-
}
|
|
486
|
-
};
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import { MailboxSpecialUse } from "@remit/domain-enums";
|
|
4
|
-
import {
|
|
5
|
-
type MailboxSyncOrderEntry,
|
|
6
|
-
mailboxSyncPriority,
|
|
7
|
-
orderMailboxesForSync,
|
|
8
|
-
} from "./mailbox-sync-order.js";
|
|
9
|
-
|
|
10
|
-
const mailbox = (
|
|
11
|
-
fullPath: string,
|
|
12
|
-
specialUse?: readonly string[],
|
|
13
|
-
): MailboxSyncOrderEntry => ({
|
|
14
|
-
mailboxId: `id-${fullPath}`,
|
|
15
|
-
fullPath,
|
|
16
|
-
specialUse,
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
describe("orderMailboxesForSync", () => {
|
|
20
|
-
it("orders INBOX first, Sent/Drafts next, Junk and Trash last", () => {
|
|
21
|
-
const mailboxes = [
|
|
22
|
-
mailbox("INBOX/Spam", [MailboxSpecialUse.Junk]),
|
|
23
|
-
mailbox("Trash", [MailboxSpecialUse.Trash]),
|
|
24
|
-
mailbox("Projects"),
|
|
25
|
-
mailbox("INBOX"),
|
|
26
|
-
mailbox("Sent", [MailboxSpecialUse.Sent]),
|
|
27
|
-
mailbox("Drafts", [MailboxSpecialUse.Drafts]),
|
|
28
|
-
];
|
|
29
|
-
|
|
30
|
-
const ordered = orderMailboxesForSync(mailboxes).map((m) => m.fullPath);
|
|
31
|
-
|
|
32
|
-
assert.deepStrictEqual(ordered, [
|
|
33
|
-
"INBOX",
|
|
34
|
-
"Sent",
|
|
35
|
-
"Drafts",
|
|
36
|
-
"Projects",
|
|
37
|
-
"INBOX/Spam",
|
|
38
|
-
"Trash",
|
|
39
|
-
]);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("places INBOX ahead of an alphabetically-earlier Junk folder", () => {
|
|
43
|
-
const ordered = orderMailboxesForSync([
|
|
44
|
-
mailbox("Bulk", [MailboxSpecialUse.Junk]),
|
|
45
|
-
mailbox("INBOX"),
|
|
46
|
-
]).map((m) => m.fullPath);
|
|
47
|
-
|
|
48
|
-
assert.deepStrictEqual(ordered, ["INBOX", "Bulk"]);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("is deterministic: ties break alphabetically by fullPath", () => {
|
|
52
|
-
const ordered = orderMailboxesForSync([
|
|
53
|
-
mailbox("Work"),
|
|
54
|
-
mailbox("Archive", [MailboxSpecialUse.Archive]),
|
|
55
|
-
mailbox("Newsletters"),
|
|
56
|
-
]).map((m) => m.fullPath);
|
|
57
|
-
|
|
58
|
-
assert.deepStrictEqual(ordered, ["Archive", "Newsletters", "Work"]);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("does not mutate the input array", () => {
|
|
62
|
-
const input = [
|
|
63
|
-
mailbox("Trash", [MailboxSpecialUse.Trash]),
|
|
64
|
-
mailbox("INBOX"),
|
|
65
|
-
];
|
|
66
|
-
const before = input.map((m) => m.fullPath);
|
|
67
|
-
orderMailboxesForSync(input);
|
|
68
|
-
assert.deepStrictEqual(
|
|
69
|
-
input.map((m) => m.fullPath),
|
|
70
|
-
before,
|
|
71
|
-
);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("treats INBOX case-insensitively", () => {
|
|
75
|
-
assert.strictEqual(mailboxSyncPriority(mailbox("inbox")), 0);
|
|
76
|
-
assert.strictEqual(mailboxSyncPriority(mailbox("Inbox")), 0);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("de-prioritises a folder carrying both a leading and a Junk flag", () => {
|
|
80
|
-
assert.ok(
|
|
81
|
-
mailboxSyncPriority(
|
|
82
|
-
mailbox("Weird", [MailboxSpecialUse.Sent, MailboxSpecialUse.Junk]),
|
|
83
|
-
) > mailboxSyncPriority(mailbox("Plain")),
|
|
84
|
-
);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("gives unflagged user folders the normal priority", () => {
|
|
88
|
-
assert.strictEqual(
|
|
89
|
-
mailboxSyncPriority(mailbox("Projects")),
|
|
90
|
-
mailboxSyncPriority(mailbox("Personal", [])),
|
|
91
|
-
);
|
|
92
|
-
});
|
|
93
|
-
});
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { MailboxSpecialUse } from "@remit/domain-enums";
|
|
2
|
-
|
|
3
|
-
export type MailboxSyncOrderEntry = {
|
|
4
|
-
mailboxId: string;
|
|
5
|
-
fullPath: string;
|
|
6
|
-
specialUse?: readonly string[];
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
// Sync priority by special-use, lowest first. Real mail leads (INBOX, then
|
|
10
|
-
// Sent and Drafts), normal folders sit in the middle at NORMAL_PRIORITY, and
|
|
11
|
-
// the low-value bulk folders (Junk/Spam, Trash) are pushed to the end so a
|
|
12
|
-
// fresh account fills its inbox before its spam (issue #567). Keyed by the
|
|
13
|
-
// bare runtime values from `@remit/domain-enums` (e.g. "Junk"), which match the
|
|
14
|
-
// values stored in the Mailbox `specialUse` DynamoDB set.
|
|
15
|
-
const INBOX_PRIORITY = 0;
|
|
16
|
-
const NORMAL_PRIORITY = 4;
|
|
17
|
-
|
|
18
|
-
const SPECIAL_USE_PRIORITY: Record<string, number> = {
|
|
19
|
-
[MailboxSpecialUse.Sent]: 1,
|
|
20
|
-
[MailboxSpecialUse.Drafts]: 2,
|
|
21
|
-
[MailboxSpecialUse.Flagged]: 3,
|
|
22
|
-
[MailboxSpecialUse.Junk]: 5,
|
|
23
|
-
[MailboxSpecialUse.Trash]: 6,
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const isInbox = (fullPath: string): boolean =>
|
|
27
|
-
fullPath.toUpperCase() === "INBOX";
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Sync priority for a single mailbox. INBOX always leads; otherwise the
|
|
31
|
-
* mailbox's special-use flags resolve to a priority, defaulting to
|
|
32
|
-
* NORMAL_PRIORITY for plain user folders and unflagged mailboxes. When a
|
|
33
|
-
* mailbox carries both a leading flag (Sent/Drafts) and a trailing one
|
|
34
|
-
* (Junk/Trash) — rare, but possible with misconfigured servers — the trailing
|
|
35
|
-
* one wins: de-prioritising a bulk folder is the safer first-impression
|
|
36
|
-
* choice.
|
|
37
|
-
*/
|
|
38
|
-
export const mailboxSyncPriority = (entry: MailboxSyncOrderEntry): number => {
|
|
39
|
-
if (isInbox(entry.fullPath)) return INBOX_PRIORITY;
|
|
40
|
-
|
|
41
|
-
const priorities = (entry.specialUse ?? [])
|
|
42
|
-
.map((flag) => SPECIAL_USE_PRIORITY[flag])
|
|
43
|
-
.filter((priority): priority is number => priority !== undefined);
|
|
44
|
-
|
|
45
|
-
if (priorities.length === 0) return NORMAL_PRIORITY;
|
|
46
|
-
|
|
47
|
-
const trailing = priorities.filter((priority) => priority > NORMAL_PRIORITY);
|
|
48
|
-
if (trailing.length > 0) return Math.max(...trailing);
|
|
49
|
-
|
|
50
|
-
return Math.min(...priorities);
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Order mailboxes for sync fan-out: INBOX first, then Sent/Drafts, then normal
|
|
55
|
-
* folders, with Junk/Spam and Trash last. Ties break alphabetically by
|
|
56
|
-
* fullPath so the order is deterministic.
|
|
57
|
-
*/
|
|
58
|
-
export const orderMailboxesForSync = <T extends MailboxSyncOrderEntry>(
|
|
59
|
-
mailboxes: readonly T[],
|
|
60
|
-
): T[] =>
|
|
61
|
-
[...mailboxes].sort((a, b) => {
|
|
62
|
-
const priorityDelta = mailboxSyncPriority(a) - mailboxSyncPriority(b);
|
|
63
|
-
if (priorityDelta !== 0) return priorityDelta;
|
|
64
|
-
return a.fullPath.localeCompare(b.fullPath);
|
|
65
|
-
});
|