@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,520 @@
|
|
|
1
|
+
import { isAcceptedSchemaVersion } from "@remit/data-ports/mutation-events";
|
|
2
|
+
import { MessageMutation, MessageStatus, MessageSyncStatus, } from "@remit/domain-enums";
|
|
3
|
+
import { buildThreadMessageTrashUpdate, guardConnectionCursor, isCursorRebuildNeeded, isMessageGoneFromOpenMailbox, MailboxCursorPausedError, restoreSourcePlacement, } from "@remit/mailbox-service";
|
|
4
|
+
import { isAccountDeleted } from "../account-check.js";
|
|
5
|
+
import { emitFrom } from "../emit.js";
|
|
6
|
+
import { findMailboxRow } from "../mailbox-row.js";
|
|
7
|
+
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
8
|
+
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
9
|
+
import { resolveExhaustedMessageDeleteFailure } from "./message-delete-terminal.js";
|
|
10
|
+
import { emitMailboxResync, emitMoveResync, probePausedPlacement, searchMailboxForHighestMessageIdUid, } from "./message-move.js";
|
|
11
|
+
import { probeableMessageIdHeader } from "./probe-message-id.js";
|
|
12
|
+
/**
|
|
13
|
+
* Delete every ThreadMessage row that points at this messageId.
|
|
14
|
+
*
|
|
15
|
+
* A single Message can have multiple ThreadMessage rows — one per mailbox
|
|
16
|
+
* the message exists in (e.g. INBOX + a label/folder copy). Permanent-delete
|
|
17
|
+
* cleanup must remove ALL of them; using `findByMessageId` (single row) leaves
|
|
18
|
+
* orphan rows in other mailboxes that then leak into their listings. See
|
|
19
|
+
* issue #212.
|
|
20
|
+
*/
|
|
21
|
+
export const deleteAllThreadMessagesForMessage = async (threadMessageService, accountConfigId, messageId) => {
|
|
22
|
+
const rows = await threadMessageService.findAllByMessageId(accountConfigId, messageId);
|
|
23
|
+
for (const row of rows) {
|
|
24
|
+
await threadMessageService.delete(row.accountConfigId, row.threadMessageId);
|
|
25
|
+
}
|
|
26
|
+
return rows.length;
|
|
27
|
+
};
|
|
28
|
+
const confirmTrashMoveUid = async (sourceConnection, destinationConnection, messageService, threadMessageService, accountConfigId, messageId, sourceMailboxPath, destinationMailboxPath, uid) => {
|
|
29
|
+
await sourceConnection.openBox(sourceMailboxPath, true);
|
|
30
|
+
if (!(await isMessageGoneFromOpenMailbox(sourceConnection, uid))) {
|
|
31
|
+
return { outcome: "still-at-source" };
|
|
32
|
+
}
|
|
33
|
+
const [message] = await messageService.get([messageId]);
|
|
34
|
+
if (!message)
|
|
35
|
+
return { outcome: "row-gone" };
|
|
36
|
+
const messageIdHeader = await probeableMessageIdHeader(threadMessageService, accountConfigId, messageId);
|
|
37
|
+
if (!messageIdHeader)
|
|
38
|
+
return { outcome: "unprobeable" };
|
|
39
|
+
const probedUid = await searchMailboxForHighestMessageIdUid(destinationConnection, destinationMailboxPath, messageIdHeader);
|
|
40
|
+
return probedUid === null
|
|
41
|
+
? { outcome: "unconfirmed" }
|
|
42
|
+
: { outcome: "confirmed", uid: probedUid };
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Handle MESSAGE_DELETE events.
|
|
46
|
+
* Either moves to Trash (IMAP MOVE) or permanently deletes (IMAP DELETE).
|
|
47
|
+
*
|
|
48
|
+
* A failing delete retries on redelivery until `receiveCount` reaches
|
|
49
|
+
* the `config.attempts.messageDelete` budget, at which point
|
|
50
|
+
* {@link resolveExhaustedMessageDeleteFailure} asks IMAP where the message
|
|
51
|
+
* actually is and settles the row into one terminal outcome (issue #980).
|
|
52
|
+
*/
|
|
53
|
+
export const handleMessageDelete = async (event, ctx, receiveCount) => {
|
|
54
|
+
const { log, pool, metrics, config } = ctx;
|
|
55
|
+
const emit = emitFrom(ctx);
|
|
56
|
+
const { account: accountService, message: messageService, threadMessage: threadMessageService, mailbox: mailboxService, secrets, } = ctx.client;
|
|
57
|
+
const { accountId, messageId, mailboxId, uid, operation, destinationMailboxId, } = event;
|
|
58
|
+
log.info({ event: event.type, accountId, messageId, mailboxId, operation }, "Handling event");
|
|
59
|
+
const account = await accountService.get(accountId);
|
|
60
|
+
if (!account) {
|
|
61
|
+
throw new Error(`Account ${accountId} not found`);
|
|
62
|
+
}
|
|
63
|
+
if (isAccountDeleted(account, log)) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const [message] = await messageService.get([messageId]);
|
|
67
|
+
// The row is gone — a permanent delete that already ran, or a
|
|
68
|
+
// reconciliation path that removed it. Nothing left to delete; ack.
|
|
69
|
+
if (!message) {
|
|
70
|
+
log.warn({ accountId, messageId, operation }, "Skipping MESSAGE_DELETE: message row no longer exists");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// This delete already settled. The guard asks for the state THIS delete
|
|
74
|
+
// wrote, the way MESSAGE_MOVE and MESSAGE_COPY ask for theirs: a move to
|
|
75
|
+
// Trash records `moving`, a permanent delete records `deleting`, and a row
|
|
76
|
+
// in the other one is a different mutation's work that this handler must
|
|
77
|
+
// not read as its own to finish.
|
|
78
|
+
//
|
|
79
|
+
// Without it a lost SQS acknowledgement re-runs the delete against a uid
|
|
80
|
+
// the source no longer holds, exhausts, and lets the terminal resolver
|
|
81
|
+
// read the source's honest "gone" as grounds to delete rows that are
|
|
82
|
+
// correct — taking spamReport, classificationState, category and the Undo
|
|
83
|
+
// target's `originalMailboxId` with them, none of which the resync
|
|
84
|
+
// re-projection can rebuild.
|
|
85
|
+
// An operation this build does not recognise gets no verdict here: it falls
|
|
86
|
+
// through to `abandonDelete` below, which is the designed refusal. Skipping
|
|
87
|
+
// it as "already settled" would swallow the very event the refusal exists to
|
|
88
|
+
// report.
|
|
89
|
+
const outstandingStatus = operation === "move_to_trash"
|
|
90
|
+
? MessageStatus.moving
|
|
91
|
+
: operation === "permanent_delete"
|
|
92
|
+
? MessageStatus.deleting
|
|
93
|
+
: undefined;
|
|
94
|
+
if (outstandingStatus !== undefined && message.status !== outstandingStatus) {
|
|
95
|
+
log.info({
|
|
96
|
+
accountId,
|
|
97
|
+
messageId,
|
|
98
|
+
operation,
|
|
99
|
+
uid: message.uid,
|
|
100
|
+
status: message.status,
|
|
101
|
+
syncStatus: message.syncStatus,
|
|
102
|
+
}, "Skipping MESSAGE_DELETE: the row no longer carries the mutation this event was enqueued for");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Re-read the folders this delete touched. A move to trash has two; a
|
|
107
|
+
* permanent delete has only the source, and it is owed one just as much —
|
|
108
|
+
* the local rows are gone while the server copy survives, so with no resync
|
|
109
|
+
* the mail is invisible until the next scheduled sync. SYNC_MESSAGES on a
|
|
110
|
+
* mailbox whose cursor is not normal routes to the rebuild, which is what
|
|
111
|
+
* re-projects the rows this handler removed (`message-sync.ts`).
|
|
112
|
+
*/
|
|
113
|
+
const emitSettleResync = async () => {
|
|
114
|
+
if (destinationMailboxId) {
|
|
115
|
+
await emitMoveResync(emit, {
|
|
116
|
+
accountId,
|
|
117
|
+
sourceMailboxId: mailboxId,
|
|
118
|
+
destinationMailboxId,
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
await emitMailboxResync(emit, { accountId, mailboxId });
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Undo the optimistic local delete, for every reason this handler has to
|
|
126
|
+
* give one up, and say so exactly once. The verdict differs — a refused
|
|
127
|
+
* event is a failure the row carries, a paused cursor is a mutation the
|
|
128
|
+
* server never heard of — but the way back is one rule, and it is the
|
|
129
|
+
* listing rows that pick it.
|
|
130
|
+
*
|
|
131
|
+
* Rows still there is the ordinary move to trash: the row goes back on the
|
|
132
|
+
* source pair, which is also the set the cursor rebuild adjudicates.
|
|
133
|
+
*
|
|
134
|
+
* No rows left is a permanent delete, which drops them at enqueue; they are
|
|
135
|
+
* denormalized off an envelope only the sync path shapes and cannot be
|
|
136
|
+
* rebuilt from here, so restoring the Message alone would leave mail nothing
|
|
137
|
+
* can list — the silent vanish rather than a visible failure. The local
|
|
138
|
+
* removal finishes instead. Nothing was expunged, so the server copy
|
|
139
|
+
* survives and the mailbox's own sync re-projects it.
|
|
140
|
+
*
|
|
141
|
+
* `alert` is a refusal or a placement the server never settled, both of
|
|
142
|
+
* which an operator adjudicates. `metric` is the routine pause the guard doc
|
|
143
|
+
* calls never-a-fault: the user's delete was silently dropped, worth
|
|
144
|
+
* counting and not worth waking anyone for. Either way `localRowsRemoved`
|
|
145
|
+
* keeps the disappearance of local mail visible.
|
|
146
|
+
*/
|
|
147
|
+
const handBackDelete = async (syncStatus, signal, reason) => {
|
|
148
|
+
const threadMessages = await threadMessageService.findAllByMessageId(account.accountConfigId, messageId);
|
|
149
|
+
const localRowsRemoved = threadMessages.length === 0;
|
|
150
|
+
const context = {
|
|
151
|
+
...signal,
|
|
152
|
+
accountId,
|
|
153
|
+
messageId,
|
|
154
|
+
uid,
|
|
155
|
+
mailboxId,
|
|
156
|
+
operation,
|
|
157
|
+
receiveCount,
|
|
158
|
+
reason,
|
|
159
|
+
localRowsRemoved,
|
|
160
|
+
};
|
|
161
|
+
const summary = localRowsRemoved
|
|
162
|
+
? "Delete given up with no listing rows left to restore; the local row was removed and the server copy was not expunged"
|
|
163
|
+
: "Delete given up; the row was handed back to its source pair";
|
|
164
|
+
if ("alert" in signal) {
|
|
165
|
+
log.error(context, summary);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
log.warn(context, summary);
|
|
169
|
+
}
|
|
170
|
+
if (localRowsRemoved) {
|
|
171
|
+
// Removing the Message row is destructive and unpredicated on its own,
|
|
172
|
+
// so it is claimed first: the row is transitioned to `deleted` from a
|
|
173
|
+
// placement this delete actually owns, and removed only if that claim
|
|
174
|
+
// wins (imap-mutations R3). A row nothing is deleting — an unrecognised
|
|
175
|
+
// operation reaches this refusal on whatever the row happens to be —
|
|
176
|
+
// is left where it is rather than destroyed on the way past.
|
|
177
|
+
const claimed = await messageService.transitionPlacement(messageId, { status: [MessageStatus.moving, MessageStatus.deleting] }, { status: MessageStatus.deleted });
|
|
178
|
+
if (!claimed) {
|
|
179
|
+
log.warn({ ...context, status: message.status }, "Delete given up with no listing rows left, and no delete outstanding on the row; the local row was left alone");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
await messageService.delete(messageId);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
await restoreSourcePlacement({ messageService, threadMessageService }, {
|
|
186
|
+
accountConfigId: account.accountConfigId,
|
|
187
|
+
messageId,
|
|
188
|
+
sourceMailboxId: mailboxId,
|
|
189
|
+
uid,
|
|
190
|
+
syncStatus,
|
|
191
|
+
// Every hand-back this handler makes is a delete's, so the mutation
|
|
192
|
+
// follows the give-up rather than being passed in beside it.
|
|
193
|
+
abandonedMutation: syncStatus === MessageSyncStatus.abandoned
|
|
194
|
+
? MessageMutation.delete
|
|
195
|
+
: MessageMutation.none,
|
|
196
|
+
threadMessages,
|
|
197
|
+
});
|
|
198
|
+
};
|
|
199
|
+
// Only an operation that explicitly says so destroys mail. The event is
|
|
200
|
+
// `JSON.parse`d and cast in the queue handler with no validation, so a
|
|
201
|
+
// missing, misspelled or future field must abandon the delete — the
|
|
202
|
+
// "anything that is not move_to_trash is an expunge" inference is the same
|
|
203
|
+
// one that destroyed mail in the service, and an unrecoverable EXPUNGE is
|
|
204
|
+
// not a default. Abandoning hands the row back where the server still has
|
|
205
|
+
// it: an invisible failure on a row the user cannot see is the shape of the
|
|
206
|
+
// incident this whole change is about. The row carries `abandoned` — the
|
|
207
|
+
// give-up value, not the transient one a redelivery follows (R3) — because
|
|
208
|
+
// the product refused something the user asked for and nothing is coming.
|
|
209
|
+
const abandonDelete = (reason, alert) => handBackDelete(MessageSyncStatus.abandoned, { alert }, reason);
|
|
210
|
+
const settleTrashMoveConfirmed = async (newUid, trashMailboxId) => {
|
|
211
|
+
await messageService.updateUid(messageId, newUid, trashMailboxId);
|
|
212
|
+
const threadMessage = await threadMessageService.findByMessageId(account.accountConfigId, messageId);
|
|
213
|
+
if (threadMessage) {
|
|
214
|
+
const args = buildThreadMessageTrashUpdate(threadMessage, newUid, trashMailboxId);
|
|
215
|
+
await threadMessageService.update(threadMessage.accountConfigId, threadMessage.threadMessageId, args.set, { composites: args.composites });
|
|
216
|
+
}
|
|
217
|
+
log.info({ messageId, newUid }, "Message moved to trash");
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* Settle the optimistic local delete when a paused cursor stops this round
|
|
221
|
+
* from issuing it. Acking on the optimistic row strands it: nothing
|
|
222
|
+
* re-enqueues a MESSAGE_DELETE, and the cursor rebuild matches rows by
|
|
223
|
+
* `(accountConfigId, mailboxId)`, so it never even sees a move-to-trash row
|
|
224
|
+
* that already names Trash (issue #1203).
|
|
225
|
+
*
|
|
226
|
+
* The delete therefore reconciles rather than waits (R2,
|
|
227
|
+
* docs/architecture/imap-mutations.md): the row is settled onto whichever
|
|
228
|
+
* pair the server can be shown to hold, and the resync plus the source's own
|
|
229
|
+
* cursor rebuild are its repair path.
|
|
230
|
+
*
|
|
231
|
+
* Undoing the local write claims nothing about the server only where the
|
|
232
|
+
* command provably never left. `commandIssued` says what this round did;
|
|
233
|
+
* `receiveCount` adds what an earlier one may have done and lost with its
|
|
234
|
+
* connection. `receiveCount === 1` alone is not that proof: an unprobeable
|
|
235
|
+
* confirmation reaches {@link settleExhaustedDelete} on the first attempt,
|
|
236
|
+
* with the MOVE already issued and the source already saying it no longer
|
|
237
|
+
* holds the message. Where either says the server may have been asked, it is
|
|
238
|
+
* asked again — {@link probePausedPlacement}, on the unguarded handle. A
|
|
239
|
+
* permanent delete has nothing to ask: its answer is the same either way,
|
|
240
|
+
* since the row is unlistable and goes.
|
|
241
|
+
*/
|
|
242
|
+
const settlePausedDelete = async (getRawConnection, commandIssued, paths) => {
|
|
243
|
+
const mayHaveIssued = commandIssued || receiveCount > 1;
|
|
244
|
+
const destinationMailboxPath = paths.destination;
|
|
245
|
+
const isProbeableTrashMove = mayHaveIssued &&
|
|
246
|
+
operation === "move_to_trash" &&
|
|
247
|
+
destinationMailboxId !== undefined &&
|
|
248
|
+
destinationMailboxPath !== undefined;
|
|
249
|
+
const placement = isProbeableTrashMove
|
|
250
|
+
? await probePausedPlacement(await getRawConnection(), {
|
|
251
|
+
messageIdHeader: await probeableMessageIdHeader(threadMessageService, account.accountConfigId, messageId),
|
|
252
|
+
sourceMailboxPath: paths.source,
|
|
253
|
+
destinationMailboxPath,
|
|
254
|
+
})
|
|
255
|
+
: { kind: "at-source" };
|
|
256
|
+
// A sighting in Trash is not proof this delete ran. The pause means the
|
|
257
|
+
// source's UIDVALIDITY has moved, so its "no" can be a recreated folder
|
|
258
|
+
// answering about mail it never held, and the hit can be an older copy of
|
|
259
|
+
// the same Message-ID — a sieve `fileinto` + `keep`, a resend — that
|
|
260
|
+
// `deriveMessageId` folds into this row; settling `moved` on it hands
|
|
261
|
+
// Empty Trash a stranger's uid. `unprobeable` is silence, and silence
|
|
262
|
+
// settles nothing either (the rule `message-copy.ts` already applies).
|
|
263
|
+
// Both hand the row back on the source pair carrying `failed`: that is the
|
|
264
|
+
// set the source's own cursor rebuild walks and adjudicates by Message-ID,
|
|
265
|
+
// which is the reconcile path (R2) Trash — not paused, no rebuild coming —
|
|
266
|
+
// does not have.
|
|
267
|
+
const unproven = placement.kind === "at-destination" || placement.kind === "unprobeable";
|
|
268
|
+
if (unproven) {
|
|
269
|
+
await handBackDelete(MessageSyncStatus.abandoned, { alert: "message_delete_paused_placement_unproven" }, placement.kind === "at-destination"
|
|
270
|
+
? "the destination holds this Message-ID but the source's uid axis has moved, so the sighting does not prove this delete ran"
|
|
271
|
+
: "no Message-ID header to ask either folder with");
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
// `gone` lands here with `at-source`: the source pair is the set the
|
|
275
|
+
// rebuild walks, and a row it cannot match against a fresh envelope
|
|
276
|
+
// snapshot is the one thing it reconciles away.
|
|
277
|
+
await handBackDelete(MessageSyncStatus.synced, { metric: "message_delete_dropped_on_pause" }, `source mailbox cursor paused, placement ${placement.kind}`);
|
|
278
|
+
}
|
|
279
|
+
await emitSettleResync();
|
|
280
|
+
};
|
|
281
|
+
const settleExhaustedDelete = async (accountConfigId, sourceMailboxPath, getConnection, settlePaused) => {
|
|
282
|
+
const settled = await resolveExhaustedMessageDeleteFailure({ messageService, threadMessageService, log }, {
|
|
283
|
+
placement: {
|
|
284
|
+
status: message.status,
|
|
285
|
+
mailboxId: message.mailboxId,
|
|
286
|
+
uid: message.uid,
|
|
287
|
+
},
|
|
288
|
+
accountId,
|
|
289
|
+
accountConfigId,
|
|
290
|
+
messageId,
|
|
291
|
+
sourceMailboxId: mailboxId,
|
|
292
|
+
uid,
|
|
293
|
+
sourceMailboxPath,
|
|
294
|
+
getConnection,
|
|
295
|
+
}).catch(async (settleError) => {
|
|
296
|
+
// The guarded probe found a mailbox whose UIDVALIDITY has moved. Nothing
|
|
297
|
+
// may be settled off a uid on a dead axis, and the pause is the routine
|
|
298
|
+
// skip `guardMailboxCursor` documents, not a fault to re-throw out of
|
|
299
|
+
// the caller's catch. The delete is still settled, on the identity axis
|
|
300
|
+
// instead: the paused settle asks by Message-ID, which a UIDVALIDITY
|
|
301
|
+
// change does not invalidate.
|
|
302
|
+
if (settleError instanceof MailboxCursorPausedError) {
|
|
303
|
+
log.info({ accountId, messageId, mailboxId, cursorState: settleError.state }, "Mailbox cursor not normal; settling the exhausted delete against the server by Message-ID");
|
|
304
|
+
await settlePaused();
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
throw settleError;
|
|
308
|
+
});
|
|
309
|
+
if (!settled)
|
|
310
|
+
return;
|
|
311
|
+
if (settled.outcome === "broken") {
|
|
312
|
+
metrics.imapFailure("MESSAGE_DELETE_EXHAUSTED", "other", 1);
|
|
313
|
+
}
|
|
314
|
+
// Both verdicts end in a row the server has contradicted, so this delete
|
|
315
|
+
// reconciles rather than waits (R2): whichever folder actually holds the
|
|
316
|
+
// message re-projects it with the server's own uid. RECONCILED, the local
|
|
317
|
+
// rows are gone and the resync rebuilds them. BROKEN, the row has just
|
|
318
|
+
// been put back at the source the server confirmed, and the resync is
|
|
319
|
+
// what carries any drift either folder has picked up since.
|
|
320
|
+
await emitSettleResync();
|
|
321
|
+
};
|
|
322
|
+
/**
|
|
323
|
+
* Decide what an unconfirmed move to Trash does with this delivery. The
|
|
324
|
+
* settle itself is `resolveExhaustedMessageDeleteFailure`, reached through
|
|
325
|
+
* the attempt budget in the catch below — the two verdicts handled here are
|
|
326
|
+
* the ones a redelivery could never answer.
|
|
327
|
+
*/
|
|
328
|
+
const settleUnconfirmedTrashMove = async (confirmation, accountConfigId, sourceMailboxPath, getConnection, settlePaused) => {
|
|
329
|
+
const context = {
|
|
330
|
+
accountId,
|
|
331
|
+
accountConfigId,
|
|
332
|
+
messageId,
|
|
333
|
+
uid,
|
|
334
|
+
mailboxId,
|
|
335
|
+
receiveCount,
|
|
336
|
+
confirmation: confirmation.outcome,
|
|
337
|
+
};
|
|
338
|
+
if (confirmation.outcome === "row-gone") {
|
|
339
|
+
log.warn(context, "Move to trash unconfirmed and the local row is already gone; nothing left to settle");
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (confirmation.outcome === "unprobeable") {
|
|
343
|
+
metrics.imapFailure("MESSAGE_DELETE_TRASH_MOVE_UNCONFIRMED", "other", 1);
|
|
344
|
+
log.info(context, "Move to trash carries no Message-ID header to probe the destination with; settling on the source's answer alone");
|
|
345
|
+
await settleExhaustedDelete(accountConfigId, sourceMailboxPath, getConnection, settlePaused);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
throw new Error(`Move to trash unconfirmed for message ${messageId} (attempt ${receiveCount}/${config.attempts.messageDelete})`);
|
|
349
|
+
};
|
|
350
|
+
if (!isAcceptedSchemaVersion(event.schemaVersion)) {
|
|
351
|
+
await abandonDelete("Refused to delete: event was minted under an unknown contract", "message_delete_unknown_schema_version");
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
await withOAuthLifecycle(buildLifecycleDeps(secrets, accountService, ctx.oauth), account, log, async (credentials) => {
|
|
355
|
+
// The folder can be deleted between enqueue and this sync, leaving a
|
|
356
|
+
// queued event pointing at a gone row. The lookup then throws
|
|
357
|
+
// NotFoundError forever, and on the account's per-group FIFO that head
|
|
358
|
+
// message stalls the whole pipeline (issues #287, #289, #290). A deleted
|
|
359
|
+
// mailbox makes the delete moot: ack with a WARN.
|
|
360
|
+
const source = await findMailboxRow(mailboxService, accountId, mailboxId);
|
|
361
|
+
if (source.kind === "gone") {
|
|
362
|
+
log.warn({ accountId, messageId, mailboxId }, "Skipping MESSAGE_DELETE: mailbox no longer exists (deleted)");
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const mailbox = source.mailbox;
|
|
366
|
+
const destination = operation === "move_to_trash" && destinationMailboxId !== undefined
|
|
367
|
+
? await findMailboxRow(mailboxService, accountId, destinationMailboxId)
|
|
368
|
+
: undefined;
|
|
369
|
+
if (destination?.kind === "gone") {
|
|
370
|
+
await abandonDelete("Refused to delete: the destination mailbox no longer exists (deleted)", "message_delete_destination_gone");
|
|
371
|
+
await emitMailboxResync(emit, { accountId, mailboxId });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const mailboxPath = mailbox.fullPath;
|
|
375
|
+
const destinationMailboxPath = destination?.mailbox.fullPath;
|
|
376
|
+
const paths = {
|
|
377
|
+
source: mailboxPath,
|
|
378
|
+
destination: destinationMailboxPath,
|
|
379
|
+
};
|
|
380
|
+
const scope = pool.borrow(account, credentials);
|
|
381
|
+
// The terminal resolver opens the source itself, and its answer now
|
|
382
|
+
// WRITES a placement, so it has to reach IMAP through the same
|
|
383
|
+
// UIDVALIDITY choke point every other outbound operation does. An
|
|
384
|
+
// unguarded probe on a folder that was deleted and recreated finds
|
|
385
|
+
// whatever the server has renumbered onto this uid, reads it as "still
|
|
386
|
+
// at source", and binds the row to a stranger the next permanent delete
|
|
387
|
+
// would expunge.
|
|
388
|
+
const getGuardedConnection = async () => guardConnectionCursor(await scope.getConnection(), { mailboxService }, accountId, mailbox);
|
|
389
|
+
const settlePaused = (commandIssued) => settlePausedDelete(scope.getConnection, commandIssued, paths);
|
|
390
|
+
// Flipped the instant the MOVE or the EXPUNGE leaves, so every settle
|
|
391
|
+
// downstream of it knows the server was asked.
|
|
392
|
+
let commandIssued = false;
|
|
393
|
+
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
394
|
+
// paused never opens a connection on a first delivery. Optimization
|
|
395
|
+
// only — the guardConnectionCursor openBox wrap below is the structural
|
|
396
|
+
// guarantee.
|
|
397
|
+
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
398
|
+
log.info({ accountId, messageId, mailboxId }, "Mailbox cursor not normal; pausing outbound delete this round and settling the row against the server");
|
|
399
|
+
await settlePaused(false).finally(() => scope.release());
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
await scope
|
|
403
|
+
.getConnection()
|
|
404
|
+
.then(async (rawConnection) => {
|
|
405
|
+
// Guard at the openBox choke point (epic #1281 invariants 3 & 5):
|
|
406
|
+
// a fresh mismatch trips the mailbox and throws once the SELECT
|
|
407
|
+
// reveals it. The delete stays applied locally either way.
|
|
408
|
+
const connection = guardConnectionCursor(rawConnection, { mailboxService }, accountId, mailbox);
|
|
409
|
+
await connection.openBox(mailboxPath, false);
|
|
410
|
+
if (operation !== "move_to_trash" &&
|
|
411
|
+
operation !== "permanent_delete") {
|
|
412
|
+
await abandonDelete("Refused to delete: event carries an unrecognized operation", "message_delete_unknown_operation");
|
|
413
|
+
}
|
|
414
|
+
else if (operation === "move_to_trash" &&
|
|
415
|
+
(!destinationMailboxPath || !destinationMailboxId)) {
|
|
416
|
+
// Defence in depth. `MessageMoveService` always sets both fields,
|
|
417
|
+
// so nothing mints such an event today; the guard exists so that
|
|
418
|
+
// a future producer that forgets one cannot reach the expunge.
|
|
419
|
+
await abandonDelete("Refused to delete: move to trash carries no destination mailbox", "message_delete_missing_destination");
|
|
420
|
+
}
|
|
421
|
+
else if (operation === "move_to_trash" &&
|
|
422
|
+
destinationMailboxPath &&
|
|
423
|
+
destinationMailboxId) {
|
|
424
|
+
// Move to Trash
|
|
425
|
+
commandIssued = true;
|
|
426
|
+
const result = await connection.moveMessages([uid], destinationMailboxPath);
|
|
427
|
+
// UIDPLUS is an extension. A server without it answers a
|
|
428
|
+
// perfectly successful MOVE with no COPYUID entry, so an empty
|
|
429
|
+
// map is UNCONFIRMED, never evidence the move failed: the server
|
|
430
|
+
// is asked before any verdict, exactly as `handleMessageMove` and
|
|
431
|
+
// `attemptMove` do (issues #979, #665). A probe the server refused
|
|
432
|
+
// says nothing either way and counts as unconfirmed.
|
|
433
|
+
const copyUid = result.uidMap.get(uid);
|
|
434
|
+
const confirmation = copyUid
|
|
435
|
+
? { outcome: "confirmed", uid: copyUid }
|
|
436
|
+
: await confirmTrashMoveUid(connection, rawConnection, messageService, threadMessageService, account.accountConfigId, messageId, mailboxPath, destinationMailboxPath, uid).catch((probeError) => {
|
|
437
|
+
log.warn({
|
|
438
|
+
messageId,
|
|
439
|
+
uid,
|
|
440
|
+
mailboxPath,
|
|
441
|
+
destinationMailboxPath,
|
|
442
|
+
probeError,
|
|
443
|
+
}, "Could not confirm the move to trash; keeping local rows");
|
|
444
|
+
return { outcome: "unconfirmed" };
|
|
445
|
+
});
|
|
446
|
+
if (confirmation.outcome === "confirmed") {
|
|
447
|
+
await settleTrashMoveConfirmed(confirmation.uid, destinationMailboxId);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
await settleUnconfirmedTrashMove(confirmation, account.accountConfigId, mailboxPath, getGuardedConnection, () => settlePaused(true));
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
// Permanent delete — reached only by `operation === "permanent_delete"`.
|
|
454
|
+
commandIssued = true;
|
|
455
|
+
await connection.deleteMessages([uid]);
|
|
456
|
+
// Delete ThreadMessage rows BEFORE the Message row to collapse the
|
|
457
|
+
// visibility window where the inbox lists a row whose backing
|
|
458
|
+
// Message has already been deleted (see issue #212). Multi-mailbox
|
|
459
|
+
// copies are handled by the helper.
|
|
460
|
+
const threadMessagesDeleted = await deleteAllThreadMessagesForMessage(threadMessageService, account.accountConfigId, messageId);
|
|
461
|
+
// Delete local Message entity
|
|
462
|
+
await messageService.delete(messageId);
|
|
463
|
+
log.info({ messageId, threadMessagesDeleted }, "Message permanently deleted");
|
|
464
|
+
}
|
|
465
|
+
})
|
|
466
|
+
.catch(async (error) => {
|
|
467
|
+
if (error instanceof MailboxCursorPausedError) {
|
|
468
|
+
log.info({ accountId, messageId, mailboxId, cursorState: error.state }, "Mailbox cursor not normal; pausing outbound delete this round and settling the row against the server");
|
|
469
|
+
await settlePaused(commandIssued);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
473
|
+
// Reconcile a permanent delete the server already applied (#212).
|
|
474
|
+
// The error text only nominates a candidate — the source box
|
|
475
|
+
// decides, since a landed move-to-trash reports that same text from
|
|
476
|
+
// a LOCAL lookup (#845) — and a probe that cannot answer counts as
|
|
477
|
+
// not-confirmed, leaving the original error to the rethrow below.
|
|
478
|
+
const isGoneFromSource = operation === "permanent_delete" &&
|
|
479
|
+
(errorMessage.includes("not found") ||
|
|
480
|
+
errorMessage.includes("NONEXISTENT")) &&
|
|
481
|
+
(await scope
|
|
482
|
+
.getConnection()
|
|
483
|
+
.then((connection) => isMessageGoneFromOpenMailbox(connection, uid))
|
|
484
|
+
.catch((probeError) => {
|
|
485
|
+
log.warn({ messageId, uid, mailboxPath, probeError }, "Could not confirm whether the message is gone; keeping local rows");
|
|
486
|
+
return false;
|
|
487
|
+
}));
|
|
488
|
+
if (isGoneFromSource) {
|
|
489
|
+
log.info({ messageId, uid }, "Message confirmed gone from IMAP, cleaning up local");
|
|
490
|
+
// Clean up local entities. Multi-mailbox copies are handled by the
|
|
491
|
+
// helper so we don't leave orphan rows (see issue #212).
|
|
492
|
+
await messageService.delete(messageId);
|
|
493
|
+
await deleteAllThreadMessagesForMessage(threadMessageService, account.accountConfigId, messageId);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
// TRYCREATE — the destination this event names is not on the
|
|
497
|
+
// server. Creating it would resurrect an empty `Trash` beside the
|
|
498
|
+
// real one and hand the name-hint rule two folders to choose
|
|
499
|
+
// between; the folder picker offers the ones that exist instead.
|
|
500
|
+
if (errorMessage.includes("TRYCREATE") && destinationMailboxPath) {
|
|
501
|
+
await abandonDelete("Refused to delete: the destination mailbox does not exist on the server", "message_delete_destination_missing");
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (receiveCount < config.attempts.messageDelete) {
|
|
505
|
+
// Transient failure — connections drop. No alarm; redelivery
|
|
506
|
+
// retries, and `failed` marks the row unsettled meanwhile. It is
|
|
507
|
+
// not a terminal signal: only the resolver below settles anything.
|
|
508
|
+
await messageService.transitionPlacement(messageId, { status: [MessageStatus.moving, MessageStatus.deleting] }, { syncStatus: MessageSyncStatus.failed });
|
|
509
|
+
throw error;
|
|
510
|
+
}
|
|
511
|
+
// Budget exhausted. Settling here is what keeps a throwing
|
|
512
|
+
// `moveMessages` inside the ceiling: re-MOVEing a uid the source no
|
|
513
|
+
// longer holds fails identically on every redelivery, which is the
|
|
514
|
+
// failure this budget exists for.
|
|
515
|
+
await settleExhaustedDelete(account.accountConfigId, mailboxPath, getGuardedConnection, () => settlePaused(commandIssued));
|
|
516
|
+
log.error({ accountId, messageId, uid, mailboxPath, error: errorMessage }, "Delete retry exhausted; settled into a terminal outcome");
|
|
517
|
+
})
|
|
518
|
+
.finally(() => scope.release());
|
|
519
|
+
});
|
|
520
|
+
};
|
|
@@ -1,50 +1,29 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
IThreadMessageRepository,
|
|
4
|
-
MessageItem,
|
|
5
|
-
} from "@remit/data-ports";
|
|
6
|
-
import { MessageMutation, MessageSyncStatus } from "@remit/domain-enums";
|
|
7
|
-
import {
|
|
8
|
-
type IImapConnection,
|
|
9
|
-
isMessageGoneFromOpenMailbox,
|
|
10
|
-
restoreSourcePlacement,
|
|
11
|
-
} from "@remit/mailbox-service";
|
|
12
|
-
|
|
1
|
+
import type { IMessageRepository, IThreadMessageRepository, MessageItem } from "@remit/data-ports";
|
|
2
|
+
import { type IImapConnection } from "@remit/mailbox-service";
|
|
13
3
|
export interface MessageMoveTerminalLogger {
|
|
14
|
-
|
|
15
|
-
|
|
4
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
5
|
+
error(obj: Record<string, unknown>, msg: string): void;
|
|
16
6
|
}
|
|
17
|
-
|
|
18
7
|
export interface ResolveExhaustedMessageMoveDeps {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
>;
|
|
23
|
-
threadMessageService: Pick<
|
|
24
|
-
IThreadMessageRepository,
|
|
25
|
-
"findAllByMessageId" | "deleteMany" | "update"
|
|
26
|
-
>;
|
|
27
|
-
log: MessageMoveTerminalLogger;
|
|
8
|
+
messageService: Pick<IMessageRepository, "deleteIfPlacement" | "transitionPlacement">;
|
|
9
|
+
threadMessageService: Pick<IThreadMessageRepository, "findAllByMessageId" | "deleteMany" | "update">;
|
|
10
|
+
log: MessageMoveTerminalLogger;
|
|
28
11
|
}
|
|
29
|
-
|
|
30
12
|
export interface ResolveExhaustedMessageMoveInput {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
13
|
+
/** Where the row stood when the caller read it; the delete holds only while it still does. */
|
|
14
|
+
placement: Pick<MessageItem, "status" | "mailboxId" | "uid">;
|
|
15
|
+
accountId: string;
|
|
16
|
+
accountConfigId: string;
|
|
17
|
+
messageId: string;
|
|
18
|
+
sourceMailboxId: string;
|
|
19
|
+
uid: number;
|
|
20
|
+
sourceMailboxPath: string;
|
|
21
|
+
getConnection: () => Promise<IImapConnection>;
|
|
40
22
|
}
|
|
41
|
-
|
|
42
23
|
export type MessageMoveTerminalOutcome = "reconciled" | "broken";
|
|
43
|
-
|
|
44
24
|
export interface ResolveExhaustedMessageMoveResult {
|
|
45
|
-
|
|
25
|
+
outcome: MessageMoveTerminalOutcome;
|
|
46
26
|
}
|
|
47
|
-
|
|
48
27
|
/**
|
|
49
28
|
* Resolve a MESSAGE_MOVE failure that has exhausted the message queue's
|
|
50
29
|
* redelivery budget into exactly one of two terminal outcomes, mirroring
|
|
@@ -95,70 +74,4 @@ export interface ResolveExhaustedMessageMoveResult {
|
|
|
95
74
|
* reverse mistake discards the row for live mail, so the cost is paid
|
|
96
75
|
* deliberately.
|
|
97
76
|
*/
|
|
98
|
-
export const resolveExhaustedMessageMoveFailure
|
|
99
|
-
deps: ResolveExhaustedMessageMoveDeps,
|
|
100
|
-
input: ResolveExhaustedMessageMoveInput,
|
|
101
|
-
): Promise<ResolveExhaustedMessageMoveResult> => {
|
|
102
|
-
const {
|
|
103
|
-
accountId,
|
|
104
|
-
accountConfigId,
|
|
105
|
-
messageId,
|
|
106
|
-
sourceMailboxId,
|
|
107
|
-
uid,
|
|
108
|
-
sourceMailboxPath,
|
|
109
|
-
getConnection,
|
|
110
|
-
} = input;
|
|
111
|
-
|
|
112
|
-
const connection = await getConnection();
|
|
113
|
-
await connection.openBox(sourceMailboxPath, true);
|
|
114
|
-
|
|
115
|
-
if (await isMessageGoneFromOpenMailbox(connection, uid)) {
|
|
116
|
-
const reconciled = await deps.messageService.deleteIfPlacement(
|
|
117
|
-
messageId,
|
|
118
|
-
input.placement,
|
|
119
|
-
);
|
|
120
|
-
deps.log.info(
|
|
121
|
-
{
|
|
122
|
-
metric: "message_move_stale_row_reconciled",
|
|
123
|
-
accountId,
|
|
124
|
-
accountConfigId,
|
|
125
|
-
messageId,
|
|
126
|
-
uid,
|
|
127
|
-
sourceMailboxPath,
|
|
128
|
-
reconciled,
|
|
129
|
-
},
|
|
130
|
-
"Message no longer at its move source after retry exhaustion (move landed server-side, or an external delete or move); stale row reconciled",
|
|
131
|
-
);
|
|
132
|
-
return { outcome: "reconciled" };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
deps.log.error(
|
|
136
|
-
{
|
|
137
|
-
alert: "message_move_failed",
|
|
138
|
-
accountId,
|
|
139
|
-
accountConfigId,
|
|
140
|
-
messageId,
|
|
141
|
-
uid,
|
|
142
|
-
sourceMailboxId,
|
|
143
|
-
sourceMailboxPath,
|
|
144
|
-
},
|
|
145
|
-
"Message move could not be pushed to IMAP after retry exhaustion; the message is still at its source — row moved back to the source folder",
|
|
146
|
-
);
|
|
147
|
-
|
|
148
|
-
await restoreSourcePlacement(deps, {
|
|
149
|
-
accountConfigId,
|
|
150
|
-
messageId,
|
|
151
|
-
sourceMailboxId,
|
|
152
|
-
uid,
|
|
153
|
-
// The probe above just confirmed the pair, so the row is a faithful
|
|
154
|
-
// projection of the source again — and the move the user asked for did
|
|
155
|
-
// not happen, with nothing left to try it. `abandoned` is the value that
|
|
156
|
-
// says so without claiming a retry is coming (imap-mutations R3); the
|
|
157
|
-
// alert stays for the operator, and the mutation names itself so the
|
|
158
|
-
// reading pane offers the right way out (issue #1229).
|
|
159
|
-
syncStatus: MessageSyncStatus.abandoned,
|
|
160
|
-
abandonedMutation: MessageMutation.move,
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
return { outcome: "broken" };
|
|
164
|
-
};
|
|
77
|
+
export declare const resolveExhaustedMessageMoveFailure: (deps: ResolveExhaustedMessageMoveDeps, input: ResolveExhaustedMessageMoveInput) => Promise<ResolveExhaustedMessageMoveResult>;
|