@remit/imap-worker 0.0.89 → 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} +24 -36
- 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} +58 -84
- 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 -224
- 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 -127
- package/src/emit.ts +0 -117
- 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 -128
- package/src/handlers/delete-account-objects.ts +0 -94
- 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 -566
- 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 -885
- 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 -752
- package/src/handlers/placement-move-push.test.ts +0 -137
- package/src/handlers/placement-move-push.ts +0 -475
- package/src/handlers/placement-push.sqlite.test.ts +0 -298
- 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 -421
- 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 -365
- package/src/handlers/sync-messages.sqlite.test.ts +0 -183
- package/src/handlers/sync-messages.ts +0 -525
- package/src/index.test.ts +0 -22
- package/src/index.ts +0 -86
- package/src/mail-sync-selection.sqlite.test.ts +0 -234
- package/src/mailbox-row.ts +0 -20
- package/src/pending-sync.sqlite.test.ts +0 -166
- package/src/poller.ts +0 -55
- package/src/processor.ts +0 -60
- package/src/queues.ts +0 -49
- package/src/scheduler/attachment-sweep.sqlite.test.ts +0 -75
- 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 -794
- package/src/test-helpers/test-world.ts +0 -427
- 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,490 @@
|
|
|
1
|
+
import { MessageMutation, MessageStatus, MessageSyncStatus, } from "@remit/domain-enums";
|
|
2
|
+
import { guardConnectionCursor, isCursorRebuildNeeded, MailboxCursorPausedError, restoreSourcePlacement, } from "@remit/mailbox-service";
|
|
3
|
+
import { isAccountDeleted } from "../account-check.js";
|
|
4
|
+
import { emitFrom } from "../emit.js";
|
|
5
|
+
import { findMailboxRow } from "../mailbox-row.js";
|
|
6
|
+
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
7
|
+
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
8
|
+
import { resolveExhaustedMessageMoveFailure } from "./message-move-terminal.js";
|
|
9
|
+
import { probeableMessageIdHeader } from "./probe-message-id.js";
|
|
10
|
+
/**
|
|
11
|
+
* Re-read both folders' counts from IMAP after a move by enqueuing the existing
|
|
12
|
+
* per-folder SYNC_MESSAGES sync. Counts are a projection of IMAP, never mutated
|
|
13
|
+
* locally — the move shifted a message between source and destination, so both
|
|
14
|
+
* folders must refresh through the one-way pipeline.
|
|
15
|
+
*/
|
|
16
|
+
export const emitMoveResync = async (emit, params) => {
|
|
17
|
+
const { accountId, sourceMailboxId, destinationMailboxId } = params;
|
|
18
|
+
await Promise.all([sourceMailboxId, destinationMailboxId].map((mailboxId) => emit({ type: "SYNC_MESSAGES", accountId, mailboxId })));
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The one-folder half of {@link emitMoveResync}, for a mutation that has no
|
|
22
|
+
* destination — a permanent delete removed local rows the server still holds,
|
|
23
|
+
* and only a resync of that folder puts them back.
|
|
24
|
+
*/
|
|
25
|
+
export const emitMailboxResync = async (emit, params) => {
|
|
26
|
+
await emit({ type: "SYNC_MESSAGES", ...params });
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* SEARCH a mailbox for copies of a message identified by its RFC822 Message-ID
|
|
30
|
+
* header and answer the HIGHEST matching uid. Read-only (EXAMINE, not SELECT) —
|
|
31
|
+
* this is a verification probe, never a write. `null` when nothing matched.
|
|
32
|
+
*
|
|
33
|
+
* The highest uid is the freshly delivered copy only when one exists; deciding
|
|
34
|
+
* that it does is the caller's job, not this function's. Every probe in this
|
|
35
|
+
* package wants the highest: uids ascend with arrival inside a UIDVALIDITY, so a
|
|
36
|
+
* copy that has just landed outranks every copy of the same Message-ID the
|
|
37
|
+
* folder already held. Where nothing was delivered, the highest is simply the
|
|
38
|
+
* newest pre-existing copy, and a caller that has not ruled that case out binds
|
|
39
|
+
* itself to unrelated mail.
|
|
40
|
+
* One Message-ID can have several server copies in one account (a sieve
|
|
41
|
+
* `fileinto` + `keep`, a resend, a repeated COPY — `deriveCopyMessageId` reuses
|
|
42
|
+
* the row, so the second copy is a second server COPY), while `deriveMessageId`
|
|
43
|
+
* is folder-independent and gives them one local row. Taking the lowest bound
|
|
44
|
+
* the fresh copy's row to an older copy's uid and orphaned the fresh one, which
|
|
45
|
+
* a later delete or Empty Trash then expunged by the wrong uid (issue #1122).
|
|
46
|
+
*
|
|
47
|
+
* The maximum is computed rather than read off the tail: RFC 3501 leaves the
|
|
48
|
+
* order of a SEARCH response unspecified, so "last returned" is not "highest".
|
|
49
|
+
*/
|
|
50
|
+
export const searchMailboxForHighestMessageIdUid = async (connection, mailboxPath, messageIdHeader) => {
|
|
51
|
+
await connection.openBox(mailboxPath, true);
|
|
52
|
+
const uids = await connection.search([
|
|
53
|
+
["HEADER", "Message-ID", messageIdHeader],
|
|
54
|
+
]);
|
|
55
|
+
if (uids.length === 0)
|
|
56
|
+
return null;
|
|
57
|
+
return uids.reduce((highest, uid) => (uid > highest ? uid : highest));
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Ask the server, by Message-ID, which folder holds a message whose mutation a
|
|
61
|
+
* paused cursor interrupted (issue #1203).
|
|
62
|
+
*
|
|
63
|
+
* Reached only on a redelivery, where "the command never left" has stopped
|
|
64
|
+
* being provable: the earlier attempt's tagged OK can be lost with the
|
|
65
|
+
* connection, so restoring the source pair on that assumption would write a
|
|
66
|
+
* settled placement naming a folder the server has already moved the mail out
|
|
67
|
+
* of. `message-copy.ts` asks the same question for the same reason.
|
|
68
|
+
*
|
|
69
|
+
* The SOURCE is asked first and a hit ends it, the gate `confirmTrashMoveUid`
|
|
70
|
+
* documents (#1122): where the mutation never ran, every hit at the destination
|
|
71
|
+
* is an older copy of the same Message-ID — a sieve `fileinto` + `keep`, a
|
|
72
|
+
* multi-label store, a resend — that `deriveMessageId` folds into this one local
|
|
73
|
+
* row, and binding to it hands a later delete somebody else's uid.
|
|
74
|
+
*
|
|
75
|
+
* The source's silence, however, is not the mirror image of its hit. Every
|
|
76
|
+
* caller here reaches this on a paused cursor, which means the source's
|
|
77
|
+
* UIDVALIDITY has moved and the folder may have been recreated holding nothing
|
|
78
|
+
* it held before — so "not at the source" is an answer about the folder, not
|
|
79
|
+
* about our mutation, and `at-destination` is a sighting rather than a
|
|
80
|
+
* confirmation. Callers settle it as unproven; see `settlePausedMove`.
|
|
81
|
+
*
|
|
82
|
+
* Identity, not position, is what makes this askable at all: a paused cursor
|
|
83
|
+
* means the source's UIDVALIDITY has moved, so every stored uid for it names
|
|
84
|
+
* something else, but a SEARCH by header is independent of the axis and answers
|
|
85
|
+
* the same question the cursor rebuild itself matches rows by. For that reason
|
|
86
|
+
* the caller passes the UNGUARDED connection — a `guardConnectionCursor` wrap
|
|
87
|
+
* refuses to open the mailbox at all, and it is bound to the source's snapshot
|
|
88
|
+
* anyway, so opening the destination through it would trip that mailbox too.
|
|
89
|
+
*/
|
|
90
|
+
export const probePausedPlacement = async (connection, input) => {
|
|
91
|
+
const { messageIdHeader, sourceMailboxPath, destinationMailboxPath } = input;
|
|
92
|
+
if (!messageIdHeader)
|
|
93
|
+
return { kind: "unprobeable" };
|
|
94
|
+
const atSource = await searchMailboxForHighestMessageIdUid(connection, sourceMailboxPath, messageIdHeader);
|
|
95
|
+
if (atSource !== null)
|
|
96
|
+
return { kind: "at-source" };
|
|
97
|
+
if (!destinationMailboxPath)
|
|
98
|
+
return { kind: "gone" };
|
|
99
|
+
const atDestination = await searchMailboxForHighestMessageIdUid(connection, destinationMailboxPath, messageIdHeader);
|
|
100
|
+
return atDestination === null
|
|
101
|
+
? { kind: "gone" }
|
|
102
|
+
: { kind: "at-destination", uid: atDestination };
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Resync the affected folders only once the IMAP move has resolved. A move that
|
|
106
|
+
* fails (or is retried) must not refresh counts off a move that didn't happen,
|
|
107
|
+
* so the resync is sequenced strictly after `performMove`.
|
|
108
|
+
*/
|
|
109
|
+
export const moveThenResync = async (performMove, resync) => {
|
|
110
|
+
await performMove();
|
|
111
|
+
await resync();
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Build the `set` and `composites` payload for the ThreadMessage update on a
|
|
115
|
+
* MESSAGE_MOVE.
|
|
116
|
+
*
|
|
117
|
+
* The CURRENT row state goes in `composites`; the NEW values go in `set`.
|
|
118
|
+
* ElectroDB uses `composites` to run the conditional check on the existing row
|
|
119
|
+
* AND to compute the previous sort-key values needed to recompute the new ones.
|
|
120
|
+
* Passing the NEW values in `composites` makes the conditional check fail with
|
|
121
|
+
* ConditionalCheckFailedException, which ElectroDB wraps as NotFoundError, and
|
|
122
|
+
* the caller silently drops the update. Same root cause as PR #186 fixed for
|
|
123
|
+
* `flag-queue.ts`.
|
|
124
|
+
*/
|
|
125
|
+
export const buildThreadMessageMoveUpdate = (threadMessage, newUid, destinationMailboxId) => ({
|
|
126
|
+
set: {
|
|
127
|
+
uid: newUid,
|
|
128
|
+
mailboxId: destinationMailboxId,
|
|
129
|
+
isDeleted: false,
|
|
130
|
+
},
|
|
131
|
+
composites: {
|
|
132
|
+
sentDate: threadMessage.sentDate,
|
|
133
|
+
mailboxId: threadMessage.mailboxId,
|
|
134
|
+
isRead: threadMessage.isRead,
|
|
135
|
+
isDeleted: threadMessage.isDeleted,
|
|
136
|
+
hasStars: threadMessage.hasStars,
|
|
137
|
+
hasAttachment: threadMessage.hasAttachment,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
/**
|
|
141
|
+
* Handle MESSAGE_MOVE events.
|
|
142
|
+
* Executes IMAP MOVE command and updates local state with new UID.
|
|
143
|
+
*
|
|
144
|
+
* A failing move retries on SQS redelivery until `receiveCount` reaches
|
|
145
|
+
* the `config.attempts.messageMove` budget, at which point
|
|
146
|
+
* {@link resolveExhaustedMessageMoveFailure} asks IMAP where the message
|
|
147
|
+
* actually is and settles the row into one terminal outcome (issue #655).
|
|
148
|
+
* Before that, `syncStatus: failed` marked every attempt and nothing ever
|
|
149
|
+
* settled the row, so an exhausted move sat `moving`/`failed` forever.
|
|
150
|
+
*/
|
|
151
|
+
export const handleMessageMove = async (event, ctx, receiveCount) => {
|
|
152
|
+
const { log, pool, metrics, config } = ctx;
|
|
153
|
+
const emit = emitFrom(ctx);
|
|
154
|
+
const { account: accountService, message: messageService, threadMessage: threadMessageService, mailbox: mailboxService, secrets, } = ctx.client;
|
|
155
|
+
const { accountId, messageId, sourceMailboxId, destinationMailboxId, uid } = event;
|
|
156
|
+
log.info({
|
|
157
|
+
event: event.type,
|
|
158
|
+
accountId,
|
|
159
|
+
messageId,
|
|
160
|
+
from: sourceMailboxId,
|
|
161
|
+
to: destinationMailboxId,
|
|
162
|
+
}, "Handling event");
|
|
163
|
+
const account = await accountService.get(accountId);
|
|
164
|
+
if (!account) {
|
|
165
|
+
throw new Error(`Account ${accountId} not found`);
|
|
166
|
+
}
|
|
167
|
+
if (isAccountDeleted(account, log)) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const [message] = await messageService.get([messageId]);
|
|
171
|
+
// The message row is already gone — some other reconciliation path deleted
|
|
172
|
+
// it. The move is moot; ack without touching IMAP.
|
|
173
|
+
if (!message) {
|
|
174
|
+
log.warn({ accountId, messageId }, "Skipping MESSAGE_MOVE: message row no longer exists");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// This move already settled — `updateUid` cleared `status: moving` once the
|
|
178
|
+
// server confirmed it. A redelivery reaching here would MOVE a UID the
|
|
179
|
+
// source no longer holds, fail, and on exhaustion read the source's honest
|
|
180
|
+
// "gone" as grounds to reconcile away a row that is correct. There is no
|
|
181
|
+
// marker to find missing (unlike FLAG_PUSH and PLACEMENT_MOVE_PUSH), so the
|
|
182
|
+
// row's own pending marker is what stands in for one.
|
|
183
|
+
//
|
|
184
|
+
// `moving` specifically, not the shared unsettled predicate: the question is
|
|
185
|
+
// whether THIS move is still outstanding, and a row a delete has since
|
|
186
|
+
// claimed is `deleting` — work that belongs to MESSAGE_DELETE, and a state
|
|
187
|
+
// this move must not read as its own to finish.
|
|
188
|
+
if (message.status !== MessageStatus.moving) {
|
|
189
|
+
log.info({ accountId, messageId, uid: message.uid, status: message.status }, "Skipping MESSAGE_MOVE: the move already settled against confirmed IMAP state");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
await withOAuthLifecycle(buildLifecycleDeps(secrets, accountService, ctx.oauth), account, log, async (credentials) => {
|
|
193
|
+
// The source folder can be deleted between enqueue and this sync, leaving
|
|
194
|
+
// a queued event pointing at a gone row. The lookup then throws
|
|
195
|
+
// NotFoundError forever, and on the account's per-group FIFO that head
|
|
196
|
+
// message stalls the whole pipeline (issues #287, #289, #290). A deleted
|
|
197
|
+
// source mailbox makes the move moot, but not the row: left `moving`, it
|
|
198
|
+
// names the destination with a uid from a folder that no longer exists,
|
|
199
|
+
// and nothing else settles it. It is reconciled away, and the destination
|
|
200
|
+
// resync re-projects the message if the MOVE did land there.
|
|
201
|
+
const source = await findMailboxRow(mailboxService, accountId, sourceMailboxId);
|
|
202
|
+
if (source.kind === "gone") {
|
|
203
|
+
const reconciled = await messageService.deleteIfPlacement(messageId, {
|
|
204
|
+
status: message.status,
|
|
205
|
+
mailboxId: message.mailboxId,
|
|
206
|
+
uid: message.uid,
|
|
207
|
+
});
|
|
208
|
+
log.warn({
|
|
209
|
+
metric: "message_move_source_gone",
|
|
210
|
+
accountId,
|
|
211
|
+
messageId,
|
|
212
|
+
mailboxId: sourceMailboxId,
|
|
213
|
+
reconciled,
|
|
214
|
+
}, "MESSAGE_MOVE not pushed: source mailbox no longer exists (deleted); stale row reconciled");
|
|
215
|
+
await emitMailboxResync(emit, {
|
|
216
|
+
accountId,
|
|
217
|
+
mailboxId: destinationMailboxId,
|
|
218
|
+
});
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const mailbox = source.mailbox;
|
|
222
|
+
const sourceMailboxPath = mailbox.fullPath;
|
|
223
|
+
const destination = await findMailboxRow(mailboxService, accountId, destinationMailboxId);
|
|
224
|
+
const settleMoved = async (newUid) => {
|
|
225
|
+
await messageService.updateUid(messageId, newUid, destinationMailboxId);
|
|
226
|
+
const threadMessage = await threadMessageService.findByMessageId(account.accountConfigId, messageId);
|
|
227
|
+
if (threadMessage) {
|
|
228
|
+
const args = buildThreadMessageMoveUpdate(threadMessage, newUid, destinationMailboxId);
|
|
229
|
+
await threadMessageService.update(threadMessage.accountConfigId, threadMessage.threadMessageId, args.set, { composites: args.composites });
|
|
230
|
+
}
|
|
231
|
+
log.info({
|
|
232
|
+
messageId,
|
|
233
|
+
oldUid: uid,
|
|
234
|
+
newUid,
|
|
235
|
+
destination: destinationMailboxId,
|
|
236
|
+
}, "Message moved successfully");
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* Put the row back on the pair it was moved off, and say so once.
|
|
240
|
+
*
|
|
241
|
+
* `metric` is the routine give-up the guard doc calls never-a-fault:
|
|
242
|
+
* the user's move was silently dropped, which is worth counting and
|
|
243
|
+
* not worth waking anyone for. `alert` is the give-up the server did
|
|
244
|
+
* not settle — the row carries `failed` and an operator adjudicates.
|
|
245
|
+
*/
|
|
246
|
+
const handBackToSource = async (syncStatus, signal, reason) => {
|
|
247
|
+
const context = {
|
|
248
|
+
...signal,
|
|
249
|
+
accountId,
|
|
250
|
+
messageId,
|
|
251
|
+
uid,
|
|
252
|
+
sourceMailboxId,
|
|
253
|
+
destinationMailboxId,
|
|
254
|
+
receiveCount,
|
|
255
|
+
reason,
|
|
256
|
+
};
|
|
257
|
+
if ("alert" in signal) {
|
|
258
|
+
log.error(context, "Move given up without a settled placement; row handed back to its source for adjudication");
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
log.warn(context, "Move not pushed to the server; row handed back to its source");
|
|
262
|
+
}
|
|
263
|
+
await restoreSourcePlacement({ messageService, threadMessageService }, {
|
|
264
|
+
accountConfigId: account.accountConfigId,
|
|
265
|
+
messageId,
|
|
266
|
+
sourceMailboxId,
|
|
267
|
+
uid,
|
|
268
|
+
syncStatus,
|
|
269
|
+
// Every hand-back this handler makes is a move's. Naming it on
|
|
270
|
+
// the row is what stops the reading pane calling a move that
|
|
271
|
+
// gave up a failed delete (issue #1229).
|
|
272
|
+
abandonedMutation: syncStatus === MessageSyncStatus.abandoned
|
|
273
|
+
? MessageMutation.move
|
|
274
|
+
: MessageMutation.none,
|
|
275
|
+
});
|
|
276
|
+
};
|
|
277
|
+
if (destination.kind === "gone") {
|
|
278
|
+
await handBackToSource(MessageSyncStatus.synced, { metric: "message_move_destination_gone" }, "destination mailbox no longer exists (deleted)");
|
|
279
|
+
await emitMailboxResync(emit, {
|
|
280
|
+
accountId,
|
|
281
|
+
mailboxId: sourceMailboxId,
|
|
282
|
+
});
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const destinationMailboxPath = destination.mailbox.fullPath;
|
|
286
|
+
const scope = pool.borrow(account, credentials);
|
|
287
|
+
// The terminal resolver opens the source itself, and its answer now
|
|
288
|
+
// WRITES a placement, so it has to reach IMAP through the same
|
|
289
|
+
// UIDVALIDITY choke point every other outbound operation does. An
|
|
290
|
+
// unguarded probe on a folder that was deleted and recreated finds
|
|
291
|
+
// whatever the server has renumbered onto this uid, reads it as "still
|
|
292
|
+
// at source", and binds the row to a stranger the next permanent delete
|
|
293
|
+
// would expunge.
|
|
294
|
+
const getGuardedConnection = async () => guardConnectionCursor(await scope.getConnection(), { mailboxService }, accountId, mailbox);
|
|
295
|
+
// A paused cursor is never acked on the optimistic row: the transition
|
|
296
|
+
// has pointed it at the destination while `uid` still names the source,
|
|
297
|
+
// and that pair strands the row for good — the cursor rebuild matches
|
|
298
|
+
// rows by `(accountConfigId, mailboxId)`, so a row naming the
|
|
299
|
+
// destination is in neither folder's set, and nothing re-enqueues a
|
|
300
|
+
// MESSAGE_MOVE (issue #1203).
|
|
301
|
+
//
|
|
302
|
+
// This move therefore reconciles rather than waits (R2,
|
|
303
|
+
// docs/architecture/imap-mutations.md): the row is settled onto
|
|
304
|
+
// whichever pair the server can be shown to hold, and the resync below
|
|
305
|
+
// plus the source's own cursor rebuild are its repair path.
|
|
306
|
+
//
|
|
307
|
+
// Putting the row back is an undo of this product's own write only where
|
|
308
|
+
// the MOVE provably never left. `commandIssued` says what this round
|
|
309
|
+
// did; `receiveCount` adds what an earlier one may have done and lost
|
|
310
|
+
// with its connection. Inferring the first from the second is what
|
|
311
|
+
// `settlePausedDelete` got wrong — attempt 1 reaches an exhausted settle
|
|
312
|
+
// too, with the MOVE already issued.
|
|
313
|
+
const settlePausedMove = async (commandIssued) => {
|
|
314
|
+
const mayHaveIssued = commandIssued || receiveCount > 1;
|
|
315
|
+
const placement = mayHaveIssued
|
|
316
|
+
? await probePausedPlacement(await scope.getConnection(), {
|
|
317
|
+
messageIdHeader: await probeableMessageIdHeader(threadMessageService, account.accountConfigId, messageId),
|
|
318
|
+
sourceMailboxPath,
|
|
319
|
+
destinationMailboxPath,
|
|
320
|
+
})
|
|
321
|
+
: { kind: "at-source" };
|
|
322
|
+
// A sighting at the destination is not proof this move ran. The pause
|
|
323
|
+
// means the source's UIDVALIDITY has moved, so its "no" can be a
|
|
324
|
+
// recreated folder answering about mail it never held, and the hit can
|
|
325
|
+
// be an older copy of the same Message-ID that `deriveMessageId` folds
|
|
326
|
+
// into this row — settling `moved` on it points the row at mail a
|
|
327
|
+
// later permanent delete would expunge. `unprobeable` is silence, and
|
|
328
|
+
// silence settles nothing either (the rule `message-copy.ts` already
|
|
329
|
+
// applies). Both hand the row back on the source pair carrying
|
|
330
|
+
// `failed`: that is the set the source's own cursor rebuild walks and
|
|
331
|
+
// adjudicates by Message-ID, which is the reconcile path (R2) the
|
|
332
|
+
// destination — not paused, no rebuild coming — does not have.
|
|
333
|
+
const unproven = placement.kind === "at-destination" ||
|
|
334
|
+
placement.kind === "unprobeable";
|
|
335
|
+
if (unproven) {
|
|
336
|
+
await handBackToSource(MessageSyncStatus.abandoned, { alert: "message_move_paused_placement_unproven" }, placement.kind === "at-destination"
|
|
337
|
+
? "destination holds this Message-ID but the source's uid axis has moved, so the sighting does not prove this move ran"
|
|
338
|
+
: "no Message-ID header to ask either folder with");
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
// `gone` lands here with `at-source`: the source pair is the set the
|
|
342
|
+
// rebuild walks, and a row it cannot match against a fresh envelope
|
|
343
|
+
// snapshot is the one thing it reconciles away.
|
|
344
|
+
await handBackToSource(MessageSyncStatus.synced, { metric: "message_move_dropped_on_pause" }, `source mailbox cursor paused, placement ${placement.kind}`);
|
|
345
|
+
}
|
|
346
|
+
await emitMoveResync(emit, {
|
|
347
|
+
accountId,
|
|
348
|
+
sourceMailboxId,
|
|
349
|
+
destinationMailboxId,
|
|
350
|
+
});
|
|
351
|
+
};
|
|
352
|
+
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
353
|
+
// paused never opens a connection on a first delivery. Optimization
|
|
354
|
+
// only — the guardConnectionCursor openBox wrap below is the structural
|
|
355
|
+
// guarantee.
|
|
356
|
+
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
357
|
+
log.info({ accountId, messageId, mailboxId: sourceMailboxId }, "Mailbox cursor not normal; pausing outbound move this round and settling the row against the server");
|
|
358
|
+
await settlePausedMove(false).finally(() => scope.release());
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// Flipped the instant the MOVE leaves, so every settle downstream of it
|
|
362
|
+
// knows the server was asked.
|
|
363
|
+
let commandIssued = false;
|
|
364
|
+
await scope
|
|
365
|
+
.getConnection()
|
|
366
|
+
.then((rawConnection) => {
|
|
367
|
+
// Guard at the openBox choke point (epic #1281 invariants 3 & 5):
|
|
368
|
+
// a fresh mismatch trips the mailbox and throws once the SELECT
|
|
369
|
+
// reveals it. The move stays applied locally either way.
|
|
370
|
+
const connection = guardConnectionCursor(rawConnection, { mailboxService }, accountId, mailbox);
|
|
371
|
+
return moveThenResync(async () => {
|
|
372
|
+
// Open source mailbox (not read-only)
|
|
373
|
+
await connection.openBox(sourceMailboxPath, false);
|
|
374
|
+
// Execute IMAP MOVE
|
|
375
|
+
commandIssued = true;
|
|
376
|
+
const result = await connection.moveMessages([uid], destinationMailboxPath);
|
|
377
|
+
// Get new UID from COPYUID response. A server without UIDPLUS
|
|
378
|
+
// answers a perfectly successful MOVE with no COPYUID entry,
|
|
379
|
+
// so an empty map is UNCONFIRMED, never evidence the message
|
|
380
|
+
// is gone: the destination is asked by Message-ID before any
|
|
381
|
+
// verdict, exactly as `attemptMove` does. Marking the row
|
|
382
|
+
// `failed` and returning (the behaviour issue #655 opens on)
|
|
383
|
+
// left it `moving`/`failed` with no DLQ entry and no metric,
|
|
384
|
+
// because a handler that returns never redelivers.
|
|
385
|
+
const newUid = result.uidMap.get(uid) ??
|
|
386
|
+
(await probeableMessageIdHeader(threadMessageService, account.accountConfigId, messageId).then((messageIdHeader) => messageIdHeader
|
|
387
|
+
? searchMailboxForHighestMessageIdUid(rawConnection, destinationMailboxPath, messageIdHeader)
|
|
388
|
+
: null));
|
|
389
|
+
if (!newUid) {
|
|
390
|
+
throw new Error(`Message move unconfirmed (no COPYUID entry, not found at ${destinationMailboxPath}) — retrying`);
|
|
391
|
+
}
|
|
392
|
+
await settleMoved(newUid);
|
|
393
|
+
}, () => emitMoveResync(emit, {
|
|
394
|
+
accountId,
|
|
395
|
+
sourceMailboxId,
|
|
396
|
+
destinationMailboxId,
|
|
397
|
+
}));
|
|
398
|
+
})
|
|
399
|
+
.catch(async (error) => {
|
|
400
|
+
if (error instanceof MailboxCursorPausedError) {
|
|
401
|
+
log.info({
|
|
402
|
+
accountId,
|
|
403
|
+
messageId,
|
|
404
|
+
mailboxId: sourceMailboxId,
|
|
405
|
+
cursorState: error.state,
|
|
406
|
+
}, "Mailbox cursor not normal; pausing outbound move this round and settling the row against the server");
|
|
407
|
+
await settlePausedMove(commandIssued);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
411
|
+
// Handle TRYCREATE - destination doesn't exist
|
|
412
|
+
if (errorMessage.includes("TRYCREATE")) {
|
|
413
|
+
log.info({ destinationMailboxPath }, "Destination mailbox doesn't exist, creating");
|
|
414
|
+
const connection = await scope.getConnection();
|
|
415
|
+
await connection.createMailbox(destinationMailboxPath);
|
|
416
|
+
// Re-throw to let the event be retried against the folder just
|
|
417
|
+
// created. Kept unconditional past the attempt budget: the
|
|
418
|
+
// destination now exists, so the record is worth redriving from
|
|
419
|
+
// the DLQ, and the terminal resolver has nothing to settle — it
|
|
420
|
+
// would find the message exactly where it started.
|
|
421
|
+
throw error;
|
|
422
|
+
}
|
|
423
|
+
if (receiveCount < config.attempts.messageMove) {
|
|
424
|
+
// Transient move failure — expected (connections drop). No
|
|
425
|
+
// alarm; queue redelivery retries, and `failed` marks the row
|
|
426
|
+
// as unsettled meanwhile. It is not a terminal signal: only the
|
|
427
|
+
// resolver below settles anything.
|
|
428
|
+
await messageService.transitionPlacement(messageId, { status: MessageStatus.moving }, { syncStatus: MessageSyncStatus.failed });
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
// Redelivery budget exhausted: resolve into exactly one of the two
|
|
432
|
+
// terminal outcomes instead of dead-lettering with no diagnosis,
|
|
433
|
+
// and never by inferring the server's state from our own failures.
|
|
434
|
+
const settled = await resolveExhaustedMessageMoveFailure({ messageService, threadMessageService, log }, {
|
|
435
|
+
placement: {
|
|
436
|
+
status: message.status,
|
|
437
|
+
mailboxId: message.mailboxId,
|
|
438
|
+
uid: message.uid,
|
|
439
|
+
},
|
|
440
|
+
accountId,
|
|
441
|
+
accountConfigId: account.accountConfigId,
|
|
442
|
+
messageId,
|
|
443
|
+
sourceMailboxId,
|
|
444
|
+
uid,
|
|
445
|
+
sourceMailboxPath,
|
|
446
|
+
getConnection: getGuardedConnection,
|
|
447
|
+
}).catch(async (settleError) => {
|
|
448
|
+
// The guarded probe found a mailbox whose UIDVALIDITY has moved.
|
|
449
|
+
// Nothing may be settled off a uid on a dead axis, and the pause
|
|
450
|
+
// is the routine skip `guardMailboxCursor` documents, not a fault
|
|
451
|
+
// to re-throw out of this catch. The row is still settled, on the
|
|
452
|
+
// identity axis instead: `settlePausedMove` asks by Message-ID,
|
|
453
|
+
// which a UIDVALIDITY change does not invalidate.
|
|
454
|
+
if (settleError instanceof MailboxCursorPausedError) {
|
|
455
|
+
log.info({
|
|
456
|
+
accountId,
|
|
457
|
+
messageId,
|
|
458
|
+
mailboxId: sourceMailboxId,
|
|
459
|
+
cursorState: settleError.state,
|
|
460
|
+
}, "Mailbox cursor not normal; settling the exhausted move against the server by Message-ID");
|
|
461
|
+
await settlePausedMove(commandIssued);
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
throw settleError;
|
|
465
|
+
});
|
|
466
|
+
if (!settled)
|
|
467
|
+
return;
|
|
468
|
+
if (settled.outcome === "broken") {
|
|
469
|
+
// Terminal and never re-thrown, so the handler-outcome series
|
|
470
|
+
// records this record as a success. Counted here or it is
|
|
471
|
+
// invisible.
|
|
472
|
+
metrics.imapFailure("MESSAGE_MOVE_EXHAUSTED", "other", 1);
|
|
473
|
+
log.error({ error: errorMessage }, "Message move retry exhausted; message still exists at its source");
|
|
474
|
+
}
|
|
475
|
+
// Both verdicts end in a row the server has contradicted, so this
|
|
476
|
+
// move reconciles rather than waits (R2). RECONCILED, the local rows
|
|
477
|
+
// are gone and whichever folder actually holds the message
|
|
478
|
+
// re-projects it with the server's own UID. BROKEN, the row has just
|
|
479
|
+
// been put back at the source the server confirmed, and the resync
|
|
480
|
+
// is what carries any drift either folder has picked up since.
|
|
481
|
+
await emitMoveResync(emit, {
|
|
482
|
+
accountId,
|
|
483
|
+
sourceMailboxId,
|
|
484
|
+
destinationMailboxId,
|
|
485
|
+
});
|
|
486
|
+
// Terminal — never re-thrown, so the caller acks either way.
|
|
487
|
+
})
|
|
488
|
+
.finally(() => scope.release());
|
|
489
|
+
});
|
|
490
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type IImapConnection } from "@remit/mailbox-service";
|
|
2
|
+
import type { ProcessContext } from "../context.js";
|
|
3
|
+
import type { PlacementMovePushEvent } from "../events.js";
|
|
4
|
+
interface MoveOutcome {
|
|
5
|
+
kind: "moved" | "not-found" | "trycreate";
|
|
6
|
+
newUid?: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Attempt the IMAP MOVE.
|
|
10
|
+
*
|
|
11
|
+
* Takes two connection handles rather than one: `sourceConnection` and
|
|
12
|
+
* `destinationConnection` wrap the SAME underlying connection but are each
|
|
13
|
+
* cursor-guarded (#1272) against their OWN mailbox — a `guardConnectionCursor`
|
|
14
|
+
* wrap binds `openBox` checks to whichever ONE mailbox snapshot it was built
|
|
15
|
+
* with, so verifying at the destination must never run through the source's
|
|
16
|
+
* guard (it would compare the destination's served UIDVALIDITY against the
|
|
17
|
+
* source's stored one and misfire).
|
|
18
|
+
*
|
|
19
|
+
* - `moved` with a `newUid` — confirmed, either via COPYUID or (see below)
|
|
20
|
+
* an explicit destination verification.
|
|
21
|
+
* - `trycreate` — the destination mailbox doesn't exist yet; the caller
|
|
22
|
+
* creates it and retries.
|
|
23
|
+
* - `not-found` — confirmed absent from BOTH source and destination. Only
|
|
24
|
+
* this outcome supersedes the pending-move marker (drops it, reconciles
|
|
25
|
+
* the row as an external delete).
|
|
26
|
+
* - Any other case throws (propagates for SQS retry, or as
|
|
27
|
+
* `MailboxCursorPausedError` for the caller's cursor-pause handling) —
|
|
28
|
+
* including an UNCONFIRMED move (no COPYUID, or an explicit "not
|
|
29
|
+
* found"/NONEXISTENT from the server) whose message is still sitting at
|
|
30
|
+
* the source. A MOVE that resolves without a uidMap entry on a
|
|
31
|
+
* non-UIDPLUS server is a plausible SUCCESS, not evidence the message is
|
|
32
|
+
* gone (PR #1289 review finding 2) — deleting the local row on that
|
|
33
|
+
* ambiguity would be data loss. `not-found` is only returned once BOTH a
|
|
34
|
+
* destination Message-ID search (when the header is known) misses AND
|
|
35
|
+
* {@link isMessageGoneFromOpenMailbox} confirms the message absent from the
|
|
36
|
+
* source — a FETCH returning no row does not, on its own, confirm anything.
|
|
37
|
+
*/
|
|
38
|
+
export declare const attemptMove: (sourceConnection: IImapConnection, destinationConnection: IImapConnection, sourceMailboxPath: string, destinationMailboxPath: string, uid: number, messageIdHeader: string | undefined) => Promise<MoveOutcome>;
|
|
39
|
+
/**
|
|
40
|
+
* Handle PLACEMENT_MOVE_PUSH events (issue #1271, epic #1281). Drains ONE
|
|
41
|
+
* pending placement-move marker: resolves the UID fresh from the Message row
|
|
42
|
+
* (never trusts a captured value — invariant 1), pushes the IMAP MOVE, and
|
|
43
|
+
* clears the marker ONLY on confirmed success. Every precedence rule (epic
|
|
44
|
+
* invariant 2) is enforced here:
|
|
45
|
+
* - Pending Remit move wins on location: nothing in this handler ever
|
|
46
|
+
* corrects `message.mailboxId` back toward the source — a resync elsewhere
|
|
47
|
+
* (message-sync.ts) never overwrites an existing row's `mailboxId` either
|
|
48
|
+
* (create-only-if-not-exists semantics), so the local move is never undone.
|
|
49
|
+
* - A NEWER local action superseding this marker (drop without pushing) —
|
|
50
|
+
* see the `message.mailboxId !== marker.destinationMailboxId` check below.
|
|
51
|
+
* - An external delete supersedes the marker entirely — the `not-found`
|
|
52
|
+
* outcome drops the marker and reconciles the stale row.
|
|
53
|
+
*
|
|
54
|
+
* Cursor-guarded (#1272, epic #1281 invariant 5): both the source and
|
|
55
|
+
* destination mailbox are checked for a non-`normal` `cursorState` before
|
|
56
|
+
* connecting, and the connection is wrapped per-mailbox via
|
|
57
|
+
* `guardConnectionCursor` so no stored UID touches the server while either
|
|
58
|
+
* mailbox's axis is being rebuilt. A trip pauses the push (routine, no
|
|
59
|
+
* alarm) — the marker stays durable and pushes again on the next event.
|
|
60
|
+
*/
|
|
61
|
+
export declare const handlePlacementMovePush: (event: PlacementMovePushEvent, ctx: ProcessContext, receiveCount: number) => Promise<void>;
|
|
62
|
+
export {};
|