@remit/imap-worker 0.0.90 → 0.0.92
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 +22 -0
- package/dist/scheduler/calendar-subscriptions.js +35 -0
- package/dist/scheduler/config.d.ts +29 -0
- package/{src/scheduler/config.ts → dist/scheduler/config.js} +9 -28
- 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/dist/scheduler/handler.js +50 -0
- 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 +66 -0
- package/dist/scheduler/run-tick.js +164 -0
- package/dist/scheduler/runner.d.ts +2 -0
- package/dist/scheduler/runner.js +102 -0
- package/dist/scheduler/stranded-placement-redrive.d.ts +16 -0
- package/dist/scheduler/stranded-placement-redrive.js +172 -0
- package/dist/scheduler/tick.d.ts +5 -0
- package/dist/scheduler/tick.js +5 -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 +79 -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/handler.ts +0 -69
- 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/runner.ts +0 -125
- 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
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { refreshFolderAppointmentLabels } from "@remit/backend/folder-role-labels";
|
|
2
|
+
import { isNotFoundError, NotFoundError } from "@remit/data-ports/errors";
|
|
3
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
4
|
+
import { FolderGoneUpstreamError, FolderRenameSettleError, isMailboxAbsentUpstream, isMailboxPresentUpstream, isMailboxRefusedUpstream, MailboxManagementService, upstreamFailureReason, } from "@remit/mailbox-service";
|
|
5
|
+
import { isAccountDeleted } from "../account-check.js";
|
|
6
|
+
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
7
|
+
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
8
|
+
/**
|
|
9
|
+
* The outcome of a create, written against the state a create in flight is:
|
|
10
|
+
* `pending` with no recorded rename target (D3, D10). Without the absence
|
|
11
|
+
* check, a create redelivered after a lost acknowledgement settles a row a
|
|
12
|
+
* rename has since claimed and kills the rename silently.
|
|
13
|
+
*/
|
|
14
|
+
const recordCreateOutcome = async (mailboxService, accountId, mailboxId, to, syncFailureReason = "") => {
|
|
15
|
+
const written = await mailboxService.transition(accountId, mailboxId, {
|
|
16
|
+
from: [MailboxSyncStatus.pending],
|
|
17
|
+
wherePendingPath: null,
|
|
18
|
+
to,
|
|
19
|
+
set: { syncFailureReason },
|
|
20
|
+
});
|
|
21
|
+
if (!written)
|
|
22
|
+
throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Pinned invariant for the whole-chain terminal guards below.
|
|
26
|
+
*
|
|
27
|
+
* Each handler wraps its `MailboxManagementService.sync*` chain in a try/catch
|
|
28
|
+
* that treats a NotFoundError as terminal (ack with a WARN, issue #289). That is
|
|
29
|
+
* only sound while the sole row a NotFoundError can refer to is the target
|
|
30
|
+
* mailbox, so its absence can only mean the user deleted the folder mid-sync —
|
|
31
|
+
* never an unrelated missing entity that should have been retried.
|
|
32
|
+
*
|
|
33
|
+
* `syncCreate` and `syncDelete` hold to that by touching nothing but the target
|
|
34
|
+
* row. `syncRename` also writes every other row that recorded the same intent,
|
|
35
|
+
* and keeps the invariant by treating a lost predicate as an outcome rather than
|
|
36
|
+
* an error: a descendant deleted mid-settle is skipped, never raised. The
|
|
37
|
+
* appointment-label refresh writes `account_setting` rows, whose reads answer
|
|
38
|
+
* absent rather than throwing. Any sync method that reads or writes a second
|
|
39
|
+
* entity owes the same, or these catches must be narrowed (match the mailboxId /
|
|
40
|
+
* re-check existence) before a NotFoundError from elsewhere is silently acked.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Handle MAILBOX_CREATE event
|
|
44
|
+
*/
|
|
45
|
+
const handleCreate = async (event, ctx) => {
|
|
46
|
+
const { log, pool } = ctx;
|
|
47
|
+
const { account: accountService, mailbox: mailboxService, secrets, } = ctx.client;
|
|
48
|
+
const { accountId, mailboxId, path, subscribe } = event;
|
|
49
|
+
log.info({ event: event.type, accountId, mailboxId, path }, "Handling event");
|
|
50
|
+
const account = await accountService.get(accountId);
|
|
51
|
+
if (!account) {
|
|
52
|
+
throw new Error(`Account ${accountId} not found`);
|
|
53
|
+
}
|
|
54
|
+
if (isAccountDeleted(account, log)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
await withOAuthLifecycle(buildLifecycleDeps(secrets, accountService, ctx.oauth), account, log, async (credentials) => {
|
|
58
|
+
const scope = pool.borrow(account, credentials);
|
|
59
|
+
const managementService = new MailboxManagementService(mailboxService, log);
|
|
60
|
+
try {
|
|
61
|
+
await managementService
|
|
62
|
+
.syncCreate(accountId, mailboxId, path, scope.getConnection, subscribe)
|
|
63
|
+
.then((result) => {
|
|
64
|
+
if (result.success) {
|
|
65
|
+
log.info({
|
|
66
|
+
accountId,
|
|
67
|
+
mailboxId,
|
|
68
|
+
path,
|
|
69
|
+
existingMailboxId: result.existing?.mailboxId,
|
|
70
|
+
}, "Mailbox created on IMAP");
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
log.error({ accountId, mailboxId, path, error: result.error }, "Failed to create mailbox on IMAP");
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
.catch(async (error) => {
|
|
77
|
+
// Check if mailbox already exists (idempotent)
|
|
78
|
+
if (isMailboxPresentUpstream(error)) {
|
|
79
|
+
log.info({ accountId, mailboxId, path }, "Mailbox already exists, marking as synced");
|
|
80
|
+
await recordCreateOutcome(mailboxService, accountId, mailboxId, MailboxSyncStatus.synced);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
await recordCreateOutcome(mailboxService, accountId, mailboxId, MailboxSyncStatus.failed, upstreamFailureReason(error));
|
|
84
|
+
if (isMailboxRefusedUpstream(error)) {
|
|
85
|
+
log.error({ accountId, mailboxId, path, intent: "create", error }, "Mail server refused the create");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
.finally(() => scope.release());
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
// The mailbox row was deleted between enqueue and this sync — the
|
|
95
|
+
// create-then-status-write throws NotFoundError, which can never
|
|
96
|
+
// succeed on retry and would poison the account's per-group FIFO
|
|
97
|
+
// forever (issue #289, same class as #287/#290). A folder the user
|
|
98
|
+
// deleted is an expected terminal outcome: ack with a WARN. Real
|
|
99
|
+
// IMAP/infra failures carry other errors and still propagate.
|
|
100
|
+
if (isNotFoundError(error)) {
|
|
101
|
+
log.warn({ accountId, mailboxId, path, eventId: event.eventId }, "Skipping MAILBOX_CREATE: mailbox no longer exists (deleted)");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Handle MAILBOX_RENAME event
|
|
110
|
+
*/
|
|
111
|
+
const handleRename = async (event, ctx) => {
|
|
112
|
+
const { log, pool } = ctx;
|
|
113
|
+
const { account: accountService, accountSetting: accountSettingService, mailbox: mailboxService, secrets, } = ctx.client;
|
|
114
|
+
const { accountId, mailboxId, oldPath, newPath } = event;
|
|
115
|
+
log.info({ event: event.type, accountId, mailboxId, oldPath, newPath }, "Handling event");
|
|
116
|
+
const account = await accountService.get(accountId);
|
|
117
|
+
if (!account) {
|
|
118
|
+
throw new Error(`Account ${accountId} not found`);
|
|
119
|
+
}
|
|
120
|
+
if (isAccountDeleted(account, log)) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
await withOAuthLifecycle(buildLifecycleDeps(secrets, accountService, ctx.oauth), account, log, async (credentials) => {
|
|
124
|
+
const scope = pool.borrow(account, credentials);
|
|
125
|
+
const managementService = new MailboxManagementService(mailboxService, log);
|
|
126
|
+
try {
|
|
127
|
+
await managementService
|
|
128
|
+
.syncRename(accountId, mailboxId, oldPath, newPath, scope.getConnection)
|
|
129
|
+
.then(async (result) => {
|
|
130
|
+
if (!result.success) {
|
|
131
|
+
log.error({ accountId, mailboxId, oldPath, newPath, error: result.error }, "Failed to rename mailbox on IMAP");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
log.info({ accountId, mailboxId, oldPath, newPath }, "Mailbox renamed on IMAP");
|
|
135
|
+
if (!result.renamed)
|
|
136
|
+
return;
|
|
137
|
+
// The path recorded beside each role appointment (#887) moves with
|
|
138
|
+
// the settle, because under D2 that is the first moment the new
|
|
139
|
+
// path is one the server holds.
|
|
140
|
+
//
|
|
141
|
+
// A label is display-only and this runs after the server rename
|
|
142
|
+
// landed, so letting it fail would take the job down the failure
|
|
143
|
+
// path and mark a completed rename refused. Logged and dropped;
|
|
144
|
+
// the next appointment write records the folder's current path
|
|
145
|
+
// anyway.
|
|
146
|
+
await refreshFolderAppointmentLabels(accountSettingService, account.accountConfigId, accountId, {
|
|
147
|
+
mailboxId,
|
|
148
|
+
oldPath: result.renamed.oldPath,
|
|
149
|
+
newPath: result.renamed.newPath,
|
|
150
|
+
}, result.renamed.delimiter).catch((error) => {
|
|
151
|
+
log.error({ accountId, mailboxId, oldPath, newPath, error }, "Renamed folder settled, but its role-appointment labels did not move");
|
|
152
|
+
});
|
|
153
|
+
})
|
|
154
|
+
.catch(async (error) => {
|
|
155
|
+
// The server executed the rename and only the local settle did not
|
|
156
|
+
// finish. That is not a refused rename: marking the rows `failed`
|
|
157
|
+
// would offer a retry of work already done and leave `fullPath`
|
|
158
|
+
// naming a path the server no longer holds. Rethrow, so the
|
|
159
|
+
// redelivery finishes the settle against an intent still standing.
|
|
160
|
+
if (error instanceof FolderRenameSettleError) {
|
|
161
|
+
log.error({ accountId, mailboxId, oldPath, newPath, error }, "Rename landed on the server; the settle will finish on redelivery");
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
// The folder the rename was to move is confirmed gone: the
|
|
165
|
+
// server refused the RENAME as non-existent and its own listing
|
|
166
|
+
// held neither path. The rename decides that, once, where the
|
|
167
|
+
// evidence is — re-reading the response code here would make
|
|
168
|
+
// every `NONEXISTENT` this code cannot classify destroy the
|
|
169
|
+
// folder's mail. Removing the root row alone is the orphaning
|
|
170
|
+
// bug: every descendant recorded the same intent (D6) and would
|
|
171
|
+
// be left `pending` at a path that does not exist, with its mail
|
|
172
|
+
// stranded out of every reader (D8).
|
|
173
|
+
if (error instanceof FolderGoneUpstreamError) {
|
|
174
|
+
log.info({ accountId, mailboxId, oldPath, intent: "rename" }, "Source mailbox not found, deleting local folder and its mail");
|
|
175
|
+
await managementService.abandonRenameSubtree(accountId, mailboxId, oldPath, newPath);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// T6. Nothing is restored — `fullPath` was never written, and
|
|
179
|
+
// each intent-carrying row keeps its target so the client can
|
|
180
|
+
// name what the rename was aiming at and offer a retry.
|
|
181
|
+
await managementService.failRename(accountId, mailboxId, oldPath, newPath, upstreamFailureReason(error));
|
|
182
|
+
if (isMailboxRefusedUpstream(error)) {
|
|
183
|
+
log.error({
|
|
184
|
+
accountId,
|
|
185
|
+
mailboxId,
|
|
186
|
+
oldPath,
|
|
187
|
+
newPath,
|
|
188
|
+
intent: "rename",
|
|
189
|
+
error,
|
|
190
|
+
}, "Mail server refused the rename");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
throw error;
|
|
194
|
+
})
|
|
195
|
+
.finally(() => scope.release());
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
// The mailbox row was deleted between enqueue and this sync — the
|
|
199
|
+
// status write-back throws NotFoundError, unretryable and would poison
|
|
200
|
+
// the account's per-group FIFO forever (issue #289 class). Ack with a
|
|
201
|
+
// WARN; real IMAP/infra failures carry other errors and still propagate.
|
|
202
|
+
if (isNotFoundError(error)) {
|
|
203
|
+
log.warn({ accountId, mailboxId, oldPath, newPath, eventId: event.eventId }, "Skipping MAILBOX_RENAME: mailbox no longer exists (deleted)");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* Handle MAILBOX_DELETE event
|
|
212
|
+
*/
|
|
213
|
+
const handleDelete = async (event, ctx) => {
|
|
214
|
+
const { log, pool } = ctx;
|
|
215
|
+
const { account: accountService, mailbox: mailboxService, secrets, } = ctx.client;
|
|
216
|
+
const { accountId, mailboxId, path } = event;
|
|
217
|
+
log.info({ event: event.type, accountId, mailboxId, path }, "Handling event");
|
|
218
|
+
const account = await accountService.get(accountId);
|
|
219
|
+
if (!account) {
|
|
220
|
+
throw new Error(`Account ${accountId} not found`);
|
|
221
|
+
}
|
|
222
|
+
if (isAccountDeleted(account, log)) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
await withOAuthLifecycle(buildLifecycleDeps(secrets, accountService, ctx.oauth), account, log, async (credentials) => {
|
|
226
|
+
const scope = pool.borrow(account, credentials);
|
|
227
|
+
const managementService = new MailboxManagementService(mailboxService, log);
|
|
228
|
+
try {
|
|
229
|
+
await managementService
|
|
230
|
+
.syncDelete(accountId, mailboxId, path, scope.getConnection)
|
|
231
|
+
.then((result) => {
|
|
232
|
+
if (result.success) {
|
|
233
|
+
log.info({ accountId, mailboxId, path }, "Mailbox deleted on IMAP");
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
log.error({ accountId, mailboxId, path, error: result.error }, "Failed to delete mailbox on IMAP");
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
.catch(async (error) => {
|
|
240
|
+
// The folder is already gone from the server, so the delete has
|
|
241
|
+
// happened. Its mail still goes with it (D8), which is the whole
|
|
242
|
+
// point of the settle.
|
|
243
|
+
if (isMailboxAbsentUpstream(error)) {
|
|
244
|
+
log.info({ accountId, mailboxId, path, intent: "delete" }, "Mailbox not found on IMAP, removing the local folder and its mail");
|
|
245
|
+
await managementService.settleDelete(accountId, mailboxId);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
// T9, INBOX included. The API refuses a delete of INBOX, so the
|
|
249
|
+
// worker's backstop is unreachable from it; if something else
|
|
250
|
+
// reaches it, the honest outcome is `failed`, not `synced` —
|
|
251
|
+
// the folder was never deleted and nothing was undone.
|
|
252
|
+
await managementService.failDelete(accountId, mailboxId, upstreamFailureReason(error));
|
|
253
|
+
if (error instanceof Error &&
|
|
254
|
+
error.message.includes("Cannot delete INBOX")) {
|
|
255
|
+
log.error({ accountId, mailboxId, path, intent: "delete" }, "Cannot delete INBOX");
|
|
256
|
+
// Don't rethrow — no retry can make this succeed.
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (isMailboxRefusedUpstream(error)) {
|
|
260
|
+
log.error({ accountId, mailboxId, path, intent: "delete", error }, "Mail server refused the delete");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
.finally(() => scope.release());
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
// The mailbox row was already deleted (a duplicate/racing delete) — the
|
|
270
|
+
// error-recovery status write throws NotFoundError, unretryable and
|
|
271
|
+
// would poison the account's per-group FIFO forever (issue #289 class).
|
|
272
|
+
// The delete has effectively happened: ack with a WARN. Real IMAP/infra
|
|
273
|
+
// failures carry other errors and still propagate.
|
|
274
|
+
if (isNotFoundError(error)) {
|
|
275
|
+
log.warn({ accountId, mailboxId, path, eventId: event.eventId }, "Skipping MAILBOX_DELETE: mailbox no longer exists (deleted)");
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
};
|
|
282
|
+
/**
|
|
283
|
+
* Process mailbox management events
|
|
284
|
+
*/
|
|
285
|
+
export const processMailboxManagement = async (event, ctx) => {
|
|
286
|
+
switch (event.type) {
|
|
287
|
+
case "MAILBOX_CREATE":
|
|
288
|
+
return handleCreate(event, ctx);
|
|
289
|
+
case "MAILBOX_RENAME":
|
|
290
|
+
return handleRename(event, ctx);
|
|
291
|
+
case "MAILBOX_DELETE":
|
|
292
|
+
return handleDelete(event, ctx);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type MailboxSyncOrderEntry = {
|
|
2
|
+
mailboxId: string;
|
|
3
|
+
fullPath: string;
|
|
4
|
+
specialUse?: readonly string[];
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Sync priority for a single mailbox. INBOX always leads; otherwise the
|
|
8
|
+
* mailbox's special-use flags resolve to a priority, defaulting to
|
|
9
|
+
* NORMAL_PRIORITY for plain user folders and unflagged mailboxes. When a
|
|
10
|
+
* mailbox carries both a leading flag (Sent/Drafts) and a trailing one
|
|
11
|
+
* (Junk/Trash) — rare, but possible with misconfigured servers — the trailing
|
|
12
|
+
* one wins: de-prioritising a bulk folder is the safer first-impression
|
|
13
|
+
* choice.
|
|
14
|
+
*/
|
|
15
|
+
export declare const mailboxSyncPriority: (entry: MailboxSyncOrderEntry) => number;
|
|
16
|
+
/**
|
|
17
|
+
* Order mailboxes for sync fan-out: INBOX first, then Sent/Drafts, then normal
|
|
18
|
+
* folders, with Junk/Spam and Trash last. Ties break alphabetically by
|
|
19
|
+
* fullPath so the order is deterministic.
|
|
20
|
+
*/
|
|
21
|
+
export declare const orderMailboxesForSync: <T extends MailboxSyncOrderEntry>(mailboxes: readonly T[]) => T[];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { MailboxSpecialUse } from "@remit/domain-enums";
|
|
2
|
+
// Sync priority by special-use, lowest first. Real mail leads (INBOX, then
|
|
3
|
+
// Sent and Drafts), normal folders sit in the middle at NORMAL_PRIORITY, and
|
|
4
|
+
// the low-value bulk folders (Junk/Spam, Trash) are pushed to the end so a
|
|
5
|
+
// fresh account fills its inbox before its spam (issue #567). Keyed by the
|
|
6
|
+
// bare runtime values from `@remit/domain-enums` (e.g. "Junk"), which match the
|
|
7
|
+
// values stored in the Mailbox `specialUse` DynamoDB set.
|
|
8
|
+
const INBOX_PRIORITY = 0;
|
|
9
|
+
const NORMAL_PRIORITY = 4;
|
|
10
|
+
const SPECIAL_USE_PRIORITY = {
|
|
11
|
+
[MailboxSpecialUse.Sent]: 1,
|
|
12
|
+
[MailboxSpecialUse.Drafts]: 2,
|
|
13
|
+
[MailboxSpecialUse.Flagged]: 3,
|
|
14
|
+
[MailboxSpecialUse.Junk]: 5,
|
|
15
|
+
[MailboxSpecialUse.Trash]: 6,
|
|
16
|
+
};
|
|
17
|
+
const isInbox = (fullPath) => fullPath.toUpperCase() === "INBOX";
|
|
18
|
+
/**
|
|
19
|
+
* Sync priority for a single mailbox. INBOX always leads; otherwise the
|
|
20
|
+
* mailbox's special-use flags resolve to a priority, defaulting to
|
|
21
|
+
* NORMAL_PRIORITY for plain user folders and unflagged mailboxes. When a
|
|
22
|
+
* mailbox carries both a leading flag (Sent/Drafts) and a trailing one
|
|
23
|
+
* (Junk/Trash) — rare, but possible with misconfigured servers — the trailing
|
|
24
|
+
* one wins: de-prioritising a bulk folder is the safer first-impression
|
|
25
|
+
* choice.
|
|
26
|
+
*/
|
|
27
|
+
export const mailboxSyncPriority = (entry) => {
|
|
28
|
+
if (isInbox(entry.fullPath))
|
|
29
|
+
return INBOX_PRIORITY;
|
|
30
|
+
const priorities = (entry.specialUse ?? [])
|
|
31
|
+
.map((flag) => SPECIAL_USE_PRIORITY[flag])
|
|
32
|
+
.filter((priority) => priority !== undefined);
|
|
33
|
+
if (priorities.length === 0)
|
|
34
|
+
return NORMAL_PRIORITY;
|
|
35
|
+
const trailing = priorities.filter((priority) => priority > NORMAL_PRIORITY);
|
|
36
|
+
if (trailing.length > 0)
|
|
37
|
+
return Math.max(...trailing);
|
|
38
|
+
return Math.min(...priorities);
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Order mailboxes for sync fan-out: INBOX first, then Sent/Drafts, then normal
|
|
42
|
+
* folders, with Junk/Spam and Trash last. Ties break alphabetically by
|
|
43
|
+
* fullPath so the order is deterministic.
|
|
44
|
+
*/
|
|
45
|
+
export const orderMailboxesForSync = (mailboxes) => [...mailboxes].sort((a, b) => {
|
|
46
|
+
const priorityDelta = mailboxSyncPriority(a) - mailboxSyncPriority(b);
|
|
47
|
+
if (priorityDelta !== 0)
|
|
48
|
+
return priorityDelta;
|
|
49
|
+
return a.fullPath.localeCompare(b.fullPath);
|
|
50
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ProcessContext } from "../context.js";
|
|
2
|
+
import type { MessageCopyEvent } from "../events.js";
|
|
3
|
+
/**
|
|
4
|
+
* Where the destination says this copy is. `unprobeable` is the row that
|
|
5
|
+
* carries no usable Message-ID header (drafts, automated senders and bounces
|
|
6
|
+
* do arrive without one): the server was never
|
|
7
|
+
* asked, so its silence says nothing.
|
|
8
|
+
*/
|
|
9
|
+
export type CopyProbe = {
|
|
10
|
+
kind: "confirmed";
|
|
11
|
+
uid: number;
|
|
12
|
+
} | {
|
|
13
|
+
kind: "absent";
|
|
14
|
+
} | {
|
|
15
|
+
kind: "unprobeable";
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Handle MESSAGE_COPY events.
|
|
19
|
+
* Executes IMAP COPY command and updates local state with new UID.
|
|
20
|
+
*
|
|
21
|
+
* IMAP COPY is not idempotent and the message queue is FIFO per account, so
|
|
22
|
+
* this handler never manufactures a retry to resolve an ambiguous answer: a
|
|
23
|
+
* second COPY duplicates the mail, and a record that keeps coming back blocks
|
|
24
|
+
* every later move, copy and delete for that account (issues #287, #289,
|
|
25
|
+
* #290). Ambiguity is resolved in place, by asking the destination.
|
|
26
|
+
*
|
|
27
|
+
* A fault the server threw is still retried up to
|
|
28
|
+
* the `config.attempts.messageCopy` budget, because it may be a dropped connection.
|
|
29
|
+
* Two guards make that safe: a redelivery asks the destination BEFORE issuing
|
|
30
|
+
* COPY again, and the last attempt settles the row instead of dead-lettering
|
|
31
|
+
* it. The copy row is a reconcile-model dependent (`copySettledMessage` writes
|
|
32
|
+
* its ThreadMessage row at `uid: 0` before the server has confirmed anything,
|
|
33
|
+
* docs/architecture/imap-mutations.md R2) and these terminal settles are its
|
|
34
|
+
* reconciliation path: every verdict the server gives an answer for settles the
|
|
35
|
+
* row out of `uid: 0`/`moving` (#1097). A server that cannot be reached at all
|
|
36
|
+
* gives no verdict — the record dead-letters with the row untouched, the same
|
|
37
|
+
* caveat `resolveExhaustedMessageMoveFailure` carries, because absence is only
|
|
38
|
+
* ever concluded from an answer.
|
|
39
|
+
*/
|
|
40
|
+
export declare const handleMessageCopy: (event: MessageCopyEvent, ctx: ProcessContext, receiveCount: number) => Promise<void>;
|