@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
|
@@ -1,569 +0,0 @@
|
|
|
1
|
-
import { getClient } from "@remit/backend/client";
|
|
2
|
-
import {
|
|
3
|
-
MessageMutation,
|
|
4
|
-
MessageStatus,
|
|
5
|
-
MessageSyncStatus,
|
|
6
|
-
} from "@remit/domain-enums";
|
|
7
|
-
import type { Logger } from "@remit/logger";
|
|
8
|
-
import {
|
|
9
|
-
guardConnectionCursor,
|
|
10
|
-
type IImapConnection,
|
|
11
|
-
isCursorRebuildNeeded,
|
|
12
|
-
MailboxCursorPausedError,
|
|
13
|
-
} from "@remit/mailbox-service";
|
|
14
|
-
import { attemptBudget } from "@remit/sqs-client/attempt-budget";
|
|
15
|
-
import { isAccountDeleted } from "../account-check.js";
|
|
16
|
-
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
17
|
-
import { createEmitEvent } from "../emit.js";
|
|
18
|
-
import type { MessageCopyEvent } from "../events.js";
|
|
19
|
-
import { findMailboxRow } from "../mailbox-row.js";
|
|
20
|
-
import type { WorkerQueues } from "../queues.js";
|
|
21
|
-
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
22
|
-
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
23
|
-
import {
|
|
24
|
-
emitMoveResync,
|
|
25
|
-
searchMailboxForHighestMessageIdUid,
|
|
26
|
-
} from "./message-move.js";
|
|
27
|
-
import { probeableMessageIdHeader } from "./probe-message-id.js";
|
|
28
|
-
|
|
29
|
-
export const getMessageCopyMaxAttempts = (
|
|
30
|
-
processEnv: NodeJS.ProcessEnv = process.env,
|
|
31
|
-
): number => attemptBudget("MESSAGE_COPY_MAX_ATTEMPTS", 3, processEnv);
|
|
32
|
-
|
|
33
|
-
export const MESSAGE_COPY_MAX_ATTEMPTS = getMessageCopyMaxAttempts();
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Where the destination says this copy is. `unprobeable` is the row that
|
|
37
|
-
* carries no usable Message-ID header (drafts, automated senders and bounces
|
|
38
|
-
* do arrive without one): the server was never
|
|
39
|
-
* asked, so its silence says nothing.
|
|
40
|
-
*/
|
|
41
|
-
export type CopyProbe =
|
|
42
|
-
| { kind: "confirmed"; uid: number }
|
|
43
|
-
| { kind: "absent" }
|
|
44
|
-
| { kind: "unprobeable" };
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Handle MESSAGE_COPY events.
|
|
48
|
-
* Executes IMAP COPY command and updates local state with new UID.
|
|
49
|
-
*
|
|
50
|
-
* IMAP COPY is not idempotent and the message queue is FIFO per account, so
|
|
51
|
-
* this handler never manufactures a retry to resolve an ambiguous answer: a
|
|
52
|
-
* second COPY duplicates the mail, and a record that keeps coming back blocks
|
|
53
|
-
* every later move, copy and delete for that account (issues #287, #289,
|
|
54
|
-
* #290). Ambiguity is resolved in place, by asking the destination.
|
|
55
|
-
*
|
|
56
|
-
* A fault the server threw is still retried up to
|
|
57
|
-
* {@link MESSAGE_COPY_MAX_ATTEMPTS}, because it may be a dropped connection.
|
|
58
|
-
* Two guards make that safe: a redelivery asks the destination BEFORE issuing
|
|
59
|
-
* COPY again, and the last attempt settles the row instead of dead-lettering
|
|
60
|
-
* it. The copy row is a reconcile-model dependent (`copySettledMessage` writes
|
|
61
|
-
* its ThreadMessage row at `uid: 0` before the server has confirmed anything,
|
|
62
|
-
* docs/architecture/imap-mutations.md R2) and these terminal settles are its
|
|
63
|
-
* reconciliation path: every verdict the server gives an answer for settles the
|
|
64
|
-
* row out of `uid: 0`/`moving` (#1097). A server that cannot be reached at all
|
|
65
|
-
* gives no verdict — the record dead-letters with the row untouched, the same
|
|
66
|
-
* caveat `resolveExhaustedMessageMoveFailure` carries, because absence is only
|
|
67
|
-
* ever concluded from an answer.
|
|
68
|
-
*/
|
|
69
|
-
export const handleMessageCopy = async (
|
|
70
|
-
event: MessageCopyEvent,
|
|
71
|
-
log: Logger,
|
|
72
|
-
queues: WorkerQueues,
|
|
73
|
-
receiveCount = 1,
|
|
74
|
-
createConnectionScope = createConnectionScopeWithCredentials,
|
|
75
|
-
): Promise<void> => {
|
|
76
|
-
const emit = createEmitEvent(queues);
|
|
77
|
-
const {
|
|
78
|
-
account: accountService,
|
|
79
|
-
message: messageService,
|
|
80
|
-
threadMessage: threadMessageService,
|
|
81
|
-
mailbox: mailboxService,
|
|
82
|
-
secrets,
|
|
83
|
-
} = await getClient();
|
|
84
|
-
|
|
85
|
-
const {
|
|
86
|
-
accountId,
|
|
87
|
-
sourceMessageId,
|
|
88
|
-
newMessageId,
|
|
89
|
-
sourceMailboxId,
|
|
90
|
-
destinationMailboxId,
|
|
91
|
-
uid,
|
|
92
|
-
} = event;
|
|
93
|
-
|
|
94
|
-
log.info(
|
|
95
|
-
{
|
|
96
|
-
event: event.type,
|
|
97
|
-
accountId,
|
|
98
|
-
sourceMessageId,
|
|
99
|
-
newMessageId,
|
|
100
|
-
from: sourceMailboxId,
|
|
101
|
-
to: destinationMailboxId,
|
|
102
|
-
},
|
|
103
|
-
"Handling event",
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
const account = await accountService.get(accountId);
|
|
107
|
-
if (!account) {
|
|
108
|
-
throw new Error(`Account ${accountId} not found`);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (isAccountDeleted(account, log)) {
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const [copyRow] = await messageService.get([newMessageId]);
|
|
116
|
-
|
|
117
|
-
// The copy row is already gone — a reconciliation path settled it. Nothing
|
|
118
|
-
// left to bind a UID to; ack.
|
|
119
|
-
if (!copyRow) {
|
|
120
|
-
log.warn(
|
|
121
|
-
{ accountId, sourceMessageId, newMessageId },
|
|
122
|
-
"Skipping MESSAGE_COPY: copy row no longer exists",
|
|
123
|
-
);
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// This copy already settled — `updateUid` cleared `status: moving` when the
|
|
128
|
-
// server confirmed it. A redelivery reaching here would COPY the message a
|
|
129
|
-
// second time, and COPY has no source-side effect to make that a no-op.
|
|
130
|
-
//
|
|
131
|
-
// `moving` specifically, not the shared unsettled predicate: the question is
|
|
132
|
-
// whether THIS copy is still outstanding, and a `deleting` row is somebody
|
|
133
|
-
// else's work — reading it as unsettled is what would let the second COPY
|
|
134
|
-
// through.
|
|
135
|
-
const copyPlacement = {
|
|
136
|
-
status: copyRow.status,
|
|
137
|
-
mailboxId: copyRow.mailboxId,
|
|
138
|
-
uid: copyRow.uid,
|
|
139
|
-
};
|
|
140
|
-
if (copyRow.status !== MessageStatus.moving) {
|
|
141
|
-
log.info(
|
|
142
|
-
{ accountId, newMessageId, uid: copyRow.uid, status: copyRow.status },
|
|
143
|
-
"Skipping MESSAGE_COPY: the copy already settled against confirmed IMAP state",
|
|
144
|
-
);
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// `failed` is an earlier attempt's mark, and the re-drive's: either may
|
|
149
|
-
// have issued a COPY whose answer was lost.
|
|
150
|
-
const earlierAttempt =
|
|
151
|
-
receiveCount > 1 || copyRow.syncStatus === MessageSyncStatus.failed;
|
|
152
|
-
|
|
153
|
-
await withOAuthLifecycle(
|
|
154
|
-
buildLifecycleDeps(secrets, accountService),
|
|
155
|
-
account,
|
|
156
|
-
log,
|
|
157
|
-
async (credentials) => {
|
|
158
|
-
// The source folder can be deleted between enqueue and this sync, leaving
|
|
159
|
-
// a queued event pointing at a gone row. The lookup then throws
|
|
160
|
-
// NotFoundError forever, and on the account's per-group FIFO that head
|
|
161
|
-
// message stalls the whole pipeline (issues #287, #289, #290). A deleted
|
|
162
|
-
// source mailbox makes the copy moot: ack with a WARN.
|
|
163
|
-
const source = await findMailboxRow(
|
|
164
|
-
mailboxService,
|
|
165
|
-
accountId,
|
|
166
|
-
sourceMailboxId,
|
|
167
|
-
);
|
|
168
|
-
if (source.kind === "gone") {
|
|
169
|
-
log.warn(
|
|
170
|
-
{ accountId, sourceMessageId, mailboxId: sourceMailboxId },
|
|
171
|
-
"Skipping MESSAGE_COPY: source mailbox no longer exists (deleted)",
|
|
172
|
-
);
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
const mailbox = source.mailbox;
|
|
176
|
-
const sourceMailboxPath = mailbox.fullPath;
|
|
177
|
-
|
|
178
|
-
const destination = await findMailboxRow(
|
|
179
|
-
mailboxService,
|
|
180
|
-
accountId,
|
|
181
|
-
destinationMailboxId,
|
|
182
|
-
);
|
|
183
|
-
if (destination.kind === "gone") {
|
|
184
|
-
const reconciled = await messageService.deleteIfPlacement(
|
|
185
|
-
newMessageId,
|
|
186
|
-
copyPlacement,
|
|
187
|
-
);
|
|
188
|
-
log.warn(
|
|
189
|
-
{
|
|
190
|
-
metric: "message_copy_destination_gone",
|
|
191
|
-
accountId,
|
|
192
|
-
sourceMessageId,
|
|
193
|
-
newMessageId,
|
|
194
|
-
mailboxId: destinationMailboxId,
|
|
195
|
-
reconciled,
|
|
196
|
-
},
|
|
197
|
-
"Skipping MESSAGE_COPY: destination mailbox no longer exists (deleted); optimistic copy row reconciled away",
|
|
198
|
-
);
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
const destinationMailboxPath = destination.mailbox.fullPath;
|
|
202
|
-
|
|
203
|
-
// The copy row carries the source's Message-ID header, so the
|
|
204
|
-
// destination can be asked where this copy is. Always on the UNGUARDED
|
|
205
|
-
// handle: a `guardConnectionCursor` wrap is bound to the SOURCE mailbox
|
|
206
|
-
// snapshot alone, and opening the destination through it trips that
|
|
207
|
-
// mailbox into a rebuild.
|
|
208
|
-
const probeDestination = async (
|
|
209
|
-
connection: IImapConnection,
|
|
210
|
-
): Promise<CopyProbe> => {
|
|
211
|
-
const messageIdHeader = await probeableMessageIdHeader(
|
|
212
|
-
threadMessageService,
|
|
213
|
-
account.accountConfigId,
|
|
214
|
-
newMessageId,
|
|
215
|
-
);
|
|
216
|
-
if (!messageIdHeader) return { kind: "unprobeable" };
|
|
217
|
-
const probedUid = await searchMailboxForHighestMessageIdUid(
|
|
218
|
-
connection,
|
|
219
|
-
destinationMailboxPath,
|
|
220
|
-
messageIdHeader,
|
|
221
|
-
);
|
|
222
|
-
return probedUid === null
|
|
223
|
-
? { kind: "absent" }
|
|
224
|
-
: { kind: "confirmed", uid: probedUid };
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
const settleCopied = async (newUid: number): Promise<void> => {
|
|
228
|
-
// `updateUid` writes the confirmed uid, `active` and `synced` in one
|
|
229
|
-
// statement, so the second write this used to make said nothing the
|
|
230
|
-
// first had not — and said it without a predicate.
|
|
231
|
-
await messageService.updateUid(
|
|
232
|
-
newMessageId,
|
|
233
|
-
newUid,
|
|
234
|
-
destinationMailboxId,
|
|
235
|
-
);
|
|
236
|
-
|
|
237
|
-
const threadMessage = await threadMessageService.findByMessageId(
|
|
238
|
-
account.accountConfigId,
|
|
239
|
-
newMessageId,
|
|
240
|
-
);
|
|
241
|
-
if (threadMessage) {
|
|
242
|
-
await threadMessageService.update(
|
|
243
|
-
threadMessage.accountConfigId,
|
|
244
|
-
threadMessage.threadMessageId,
|
|
245
|
-
{ uid: newUid },
|
|
246
|
-
{
|
|
247
|
-
composites: {
|
|
248
|
-
sentDate: threadMessage.sentDate,
|
|
249
|
-
mailboxId: threadMessage.mailboxId,
|
|
250
|
-
isRead: threadMessage.isRead,
|
|
251
|
-
isDeleted: threadMessage.isDeleted,
|
|
252
|
-
hasStars: threadMessage.hasStars,
|
|
253
|
-
hasAttachment: threadMessage.hasAttachment,
|
|
254
|
-
},
|
|
255
|
-
},
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
log.info(
|
|
260
|
-
{
|
|
261
|
-
sourceMessageId,
|
|
262
|
-
newMessageId,
|
|
263
|
-
oldUid: uid,
|
|
264
|
-
newUid,
|
|
265
|
-
destination: destinationMailboxPath,
|
|
266
|
-
},
|
|
267
|
-
"Message copied successfully",
|
|
268
|
-
);
|
|
269
|
-
};
|
|
270
|
-
|
|
271
|
-
// The destination answered that it does not hold this message, so the
|
|
272
|
-
// copy never landed and the optimistic row records something the server
|
|
273
|
-
// does not have. Deleting it (with its ThreadMessage rows) is the exact
|
|
274
|
-
// reality, the same terminal outcome `resolveExhaustedMessageMoveFailure`
|
|
275
|
-
// calls `reconciled`. Routine: metric, no alarm.
|
|
276
|
-
const settleNeverLanded = async (reason: string): Promise<void> => {
|
|
277
|
-
const reconciled = await messageService.deleteIfPlacement(
|
|
278
|
-
newMessageId,
|
|
279
|
-
copyPlacement,
|
|
280
|
-
);
|
|
281
|
-
log.info(
|
|
282
|
-
{
|
|
283
|
-
metric: "message_copy_not_landed",
|
|
284
|
-
accountId,
|
|
285
|
-
sourceMessageId,
|
|
286
|
-
newMessageId,
|
|
287
|
-
destination: destinationMailboxPath,
|
|
288
|
-
reconciled,
|
|
289
|
-
reason,
|
|
290
|
-
},
|
|
291
|
-
"Copy absent from its destination; optimistic copy row reconciled away",
|
|
292
|
-
);
|
|
293
|
-
};
|
|
294
|
-
|
|
295
|
-
// The server never answered where the copy is, so nothing here may
|
|
296
|
-
// delete a row that might describe real mail. The row is settled out of
|
|
297
|
-
// `moving` instead — `status: deleted` is the state this handler already
|
|
298
|
-
// writes for a copy that will not happen, and it keeps `holdsCopyOf`
|
|
299
|
-
// answering true so a sighting at the destination cannot drag the SOURCE
|
|
300
|
-
// row out of the folder it was copied from.
|
|
301
|
-
//
|
|
302
|
-
// The cost, paid deliberately: if that COPY did land, this row is the
|
|
303
|
-
// only one the copy will ever have — `holdsCopyOf` tests row existence
|
|
304
|
-
// alone (`message-sync.ts`), so no sync creates a second one — and
|
|
305
|
-
// `deleted` hides it. Real mail then sits at the destination on the
|
|
306
|
-
// server and invisible in the product until an operator acts on the
|
|
307
|
-
// alert. `settleNeverLanded` has the opposite property: it deletes only
|
|
308
|
-
// what the server denied holding, and a later sync re-projects anything
|
|
309
|
-
// that turns out to be there. Silence buys the safer half of one pair
|
|
310
|
-
// and the worse half of the other.
|
|
311
|
-
const settleBroken = async (reason: string): Promise<void> => {
|
|
312
|
-
// A transition, not a plain update (imap-mutations R3): the copy row
|
|
313
|
-
// is only this handler's to settle while its own placement is still
|
|
314
|
-
// unsettled. The row it leaves is `deleted`, so no listing carries it
|
|
315
|
-
// and the marker is read by nothing — it is written anyway, because a
|
|
316
|
-
// give-up that does not name itself is the gap #1229 came from.
|
|
317
|
-
await messageService.transitionPlacement(
|
|
318
|
-
newMessageId,
|
|
319
|
-
{ status: MessageStatus.moving },
|
|
320
|
-
{
|
|
321
|
-
status: MessageStatus.deleted,
|
|
322
|
-
syncStatus: MessageSyncStatus.abandoned,
|
|
323
|
-
abandonedMutation: MessageMutation.copy,
|
|
324
|
-
},
|
|
325
|
-
);
|
|
326
|
-
log.error(
|
|
327
|
-
{
|
|
328
|
-
alert: "message_copy_unconfirmed",
|
|
329
|
-
accountId,
|
|
330
|
-
sourceMessageId,
|
|
331
|
-
newMessageId,
|
|
332
|
-
uid,
|
|
333
|
-
destination: destinationMailboxPath,
|
|
334
|
-
reason,
|
|
335
|
-
},
|
|
336
|
-
"Copy could not be bound to a destination UID; row settled failed for operator investigation",
|
|
337
|
-
);
|
|
338
|
-
};
|
|
339
|
-
|
|
340
|
-
const scope = createConnectionScope(account, credentials);
|
|
341
|
-
|
|
342
|
-
// A paused cursor settles rather than acks: acking leaves the optimistic
|
|
343
|
-
// row at `uid: 0`/`moving` with nothing to re-enqueue it and no folder
|
|
344
|
-
// set for the cursor rebuild to find it in (issue #1203). Like every
|
|
345
|
-
// other verdict on this row, it reconciles rather than waits (R2,
|
|
346
|
-
// docs/architecture/imap-mutations.md) — the settle below IS the
|
|
347
|
-
// reconciliation path, since the destination is not paused and no
|
|
348
|
-
// rebuild is coming for it.
|
|
349
|
-
//
|
|
350
|
-
// What may be concluded from the pause depends on what was issued, and
|
|
351
|
-
// `receiveCount` alone does not say: `commandIssued` is what this round
|
|
352
|
-
// did, a redelivery is what an earlier one may have done and lost with
|
|
353
|
-
// its connection. Where neither says the server may have been asked, the
|
|
354
|
-
// row records something the server was never told about and reconciling
|
|
355
|
-
// it away is exact. Otherwise this row may be the only record real mail
|
|
356
|
-
// will ever have, and deleting it would let the next destination sync
|
|
357
|
-
// repoint the SOURCE row out of the folder the server still holds it in.
|
|
358
|
-
// The pause is on the source and says nothing about the destination,
|
|
359
|
-
// which is probed on the unguarded handle anyway, so the destination is
|
|
360
|
-
// asked before anything is deleted and silence settles broken.
|
|
361
|
-
//
|
|
362
|
-
// A settle is a settle, so both folders re-read their counts from IMAP,
|
|
363
|
-
// the same resync the move and delete handlers run: the destination is
|
|
364
|
-
// not paused, so nothing else is coming to re-project it.
|
|
365
|
-
const settlePausedCopy = async (
|
|
366
|
-
commandIssued: boolean,
|
|
367
|
-
): Promise<void> => {
|
|
368
|
-
const mayHaveIssued = commandIssued || earlierAttempt;
|
|
369
|
-
|
|
370
|
-
if (!mayHaveIssued) {
|
|
371
|
-
await settleNeverLanded(
|
|
372
|
-
"source mailbox cursor paused before the copy",
|
|
373
|
-
);
|
|
374
|
-
} else {
|
|
375
|
-
const probe = await probeDestination(await scope.getConnection());
|
|
376
|
-
if (probe.kind === "confirmed") {
|
|
377
|
-
await settleCopied(probe.uid);
|
|
378
|
-
} else if (probe.kind === "absent") {
|
|
379
|
-
await settleNeverLanded(
|
|
380
|
-
"redelivered onto a paused source cursor, absent from destination",
|
|
381
|
-
);
|
|
382
|
-
} else {
|
|
383
|
-
await settleBroken(
|
|
384
|
-
"redelivered onto a paused source cursor with no Message-ID to ask the destination with",
|
|
385
|
-
);
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
await emitMoveResync(emit, {
|
|
390
|
-
accountId,
|
|
391
|
-
sourceMailboxId,
|
|
392
|
-
destinationMailboxId,
|
|
393
|
-
});
|
|
394
|
-
};
|
|
395
|
-
|
|
396
|
-
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
397
|
-
// paused never even opens a connection on a first delivery. Optimization
|
|
398
|
-
// only — the guardConnectionCursor openBox wrap below is the structural
|
|
399
|
-
// guarantee.
|
|
400
|
-
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
401
|
-
log.info(
|
|
402
|
-
{ accountId, sourceMessageId, mailboxId: sourceMailboxId },
|
|
403
|
-
"Mailbox cursor not normal; pausing outbound copy this round",
|
|
404
|
-
);
|
|
405
|
-
await settlePausedCopy(false).finally(() => scope.disconnect());
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// Flipped the instant the COPY leaves, so every settle downstream of it
|
|
410
|
-
// knows the server was asked. IMAP COPY is not idempotent, so this is
|
|
411
|
-
// the flag that must never be inferred.
|
|
412
|
-
let commandIssued = false;
|
|
413
|
-
|
|
414
|
-
await scope
|
|
415
|
-
.getConnection()
|
|
416
|
-
.then(async (rawConnection) => {
|
|
417
|
-
// Guard at the openBox choke point (epic #1281 invariants 3 & 5):
|
|
418
|
-
// a fresh mismatch trips the mailbox and throws once the SELECT
|
|
419
|
-
// reveals it.
|
|
420
|
-
const connection = guardConnectionCursor(
|
|
421
|
-
rawConnection,
|
|
422
|
-
{ mailboxService },
|
|
423
|
-
accountId,
|
|
424
|
-
mailbox,
|
|
425
|
-
);
|
|
426
|
-
|
|
427
|
-
// A redelivery means the previous attempt threw, which does not
|
|
428
|
-
// mean its COPY failed — the tagged OK can be lost with the
|
|
429
|
-
// connection. Ask before copying again, or the retry is what
|
|
430
|
-
// duplicates the mail. `absent` is an answer the server gave, so
|
|
431
|
-
// copying again is safe; `unprobeable` is silence, and copying on
|
|
432
|
-
// silence is the duplicate this guard exists to prevent.
|
|
433
|
-
if (earlierAttempt) {
|
|
434
|
-
const earlier = await probeDestination(rawConnection);
|
|
435
|
-
if (earlier.kind === "confirmed") {
|
|
436
|
-
await settleCopied(earlier.uid);
|
|
437
|
-
return;
|
|
438
|
-
}
|
|
439
|
-
if (earlier.kind === "unprobeable") {
|
|
440
|
-
await settleBroken(
|
|
441
|
-
"redelivered with no Message-ID to ask the destination with",
|
|
442
|
-
);
|
|
443
|
-
return;
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// Open source mailbox (read-only is fine for COPY)
|
|
448
|
-
await connection.openBox(sourceMailboxPath, true);
|
|
449
|
-
|
|
450
|
-
// Execute IMAP COPY
|
|
451
|
-
commandIssued = true;
|
|
452
|
-
const result = await connection.copyMessages(
|
|
453
|
-
[uid],
|
|
454
|
-
destinationMailboxPath,
|
|
455
|
-
);
|
|
456
|
-
|
|
457
|
-
const copiedUid = result.uidMap.get(uid);
|
|
458
|
-
if (copiedUid) {
|
|
459
|
-
await settleCopied(copiedUid);
|
|
460
|
-
return;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// A server without UIDPLUS answers a perfectly successful COPY
|
|
464
|
-
// with no COPYUID entry, so an empty map is UNCONFIRMED, never
|
|
465
|
-
// evidence the server matched nothing (#1097). The destination is
|
|
466
|
-
// asked before any verdict, as `message-move.ts` does (#912, #979).
|
|
467
|
-
const probe = await probeDestination(rawConnection);
|
|
468
|
-
if (probe.kind === "confirmed") {
|
|
469
|
-
await settleCopied(probe.uid);
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
if (probe.kind === "absent") {
|
|
473
|
-
await settleNeverLanded(
|
|
474
|
-
"no COPYUID entry, absent from destination",
|
|
475
|
-
);
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
await settleBroken(
|
|
479
|
-
"no COPYUID entry and no Message-ID to probe with",
|
|
480
|
-
);
|
|
481
|
-
})
|
|
482
|
-
.catch(async (error: unknown) => {
|
|
483
|
-
if (error instanceof MailboxCursorPausedError) {
|
|
484
|
-
log.info(
|
|
485
|
-
{
|
|
486
|
-
accountId,
|
|
487
|
-
sourceMessageId,
|
|
488
|
-
mailboxId: sourceMailboxId,
|
|
489
|
-
cursorState: error.state,
|
|
490
|
-
},
|
|
491
|
-
"Mailbox cursor not normal; pausing outbound copy this round",
|
|
492
|
-
);
|
|
493
|
-
await settlePausedCopy(commandIssued);
|
|
494
|
-
return;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
const errorMessage =
|
|
498
|
-
error instanceof Error ? error.message : String(error);
|
|
499
|
-
|
|
500
|
-
// Handle TRYCREATE - destination doesn't exist
|
|
501
|
-
if (errorMessage.includes("TRYCREATE")) {
|
|
502
|
-
log.info(
|
|
503
|
-
{ destinationMailboxPath },
|
|
504
|
-
"Destination mailbox doesn't exist, creating",
|
|
505
|
-
);
|
|
506
|
-
const connection = await scope.getConnection();
|
|
507
|
-
await connection.createMailbox(destinationMailboxPath);
|
|
508
|
-
// Re-throw to let the event be retried against the folder just
|
|
509
|
-
// created. Kept unconditional past the attempt budget, as the
|
|
510
|
-
// move path does: the destination now exists, so the record is
|
|
511
|
-
// worth redriving from the DLQ.
|
|
512
|
-
throw error;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// Handle source message not found on IMAP - already deleted (fail the copy)
|
|
516
|
-
if (
|
|
517
|
-
errorMessage.includes("not found") ||
|
|
518
|
-
errorMessage.includes("NONEXISTENT")
|
|
519
|
-
) {
|
|
520
|
-
log.info(
|
|
521
|
-
{ sourceMessageId, uid },
|
|
522
|
-
"Source message not found on IMAP, marking copy as failed",
|
|
523
|
-
);
|
|
524
|
-
await messageService.transitionPlacement(
|
|
525
|
-
newMessageId,
|
|
526
|
-
{ status: MessageStatus.moving },
|
|
527
|
-
{
|
|
528
|
-
status: MessageStatus.deleted,
|
|
529
|
-
syncStatus: MessageSyncStatus.abandoned,
|
|
530
|
-
abandonedMutation: MessageMutation.copy,
|
|
531
|
-
},
|
|
532
|
-
);
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
if (receiveCount < MESSAGE_COPY_MAX_ATTEMPTS) {
|
|
537
|
-
// Transient copy failure — expected (connections drop). Queue
|
|
538
|
-
// redelivery retries, and the probe above keeps the retry from
|
|
539
|
-
// copying the message twice.
|
|
540
|
-
await messageService.transitionPlacement(
|
|
541
|
-
newMessageId,
|
|
542
|
-
{ status: MessageStatus.moving },
|
|
543
|
-
{ syncStatus: MessageSyncStatus.failed },
|
|
544
|
-
);
|
|
545
|
-
throw error;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
// Redelivery budget exhausted. Dead-lettering here would leave the
|
|
549
|
-
// row at `uid: 0`/`moving` with nothing left to settle it and the
|
|
550
|
-
// account's FIFO group still holding the record, so the row is
|
|
551
|
-
// resolved into one terminal outcome instead — never by inferring
|
|
552
|
-
// the server's state from our own failures. A server that cannot be
|
|
553
|
-
// reached reaches no verdict: the probe throws and the record
|
|
554
|
-
// dead-letters with the row untouched.
|
|
555
|
-
const probe = await probeDestination(await scope.getConnection());
|
|
556
|
-
if (probe.kind === "confirmed") {
|
|
557
|
-
await settleCopied(probe.uid);
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
if (probe.kind === "absent") {
|
|
561
|
-
await settleNeverLanded(`retry exhausted: ${errorMessage}`);
|
|
562
|
-
return;
|
|
563
|
-
}
|
|
564
|
-
await settleBroken(`retry exhausted: ${errorMessage}`);
|
|
565
|
-
})
|
|
566
|
-
.finally(() => scope.disconnect());
|
|
567
|
-
},
|
|
568
|
-
);
|
|
569
|
-
};
|