@remit/imap-worker 0.0.90 → 0.0.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account-check.d.ts +27 -0
- package/dist/account-check.js +70 -0
- package/dist/body-sync-gate.d.ts +12 -0
- package/dist/body-sync-gate.js +53 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +193 -0
- package/dist/config-env.d.ts +5 -0
- package/dist/config-env.js +37 -0
- package/dist/connection-pool.d.ts +22 -0
- package/dist/connection-pool.js +97 -0
- package/dist/connection-scope.d.ts +8 -0
- package/dist/connection-scope.js +10 -0
- package/dist/context.d.ts +46 -0
- package/dist/context.js +15 -0
- package/dist/e2e-processor-shim.d.ts +2 -0
- package/dist/e2e-processor-shim.js +181 -0
- package/dist/emit.d.ts +15 -0
- package/dist/emit.js +76 -0
- package/dist/events.d.ts +203 -0
- package/dist/events.js +1 -0
- package/dist/failure-kind.d.ts +10 -0
- package/{src/failure-kind.ts → dist/failure-kind.js} +6 -6
- package/dist/handlers/append-sent-message.d.ts +3 -0
- package/dist/handlers/append-sent-message.js +152 -0
- package/dist/handlers/delete-account-objects.d.ts +3 -0
- package/dist/handlers/delete-account-objects.js +35 -0
- package/dist/handlers/empty-trash.d.ts +14 -0
- package/dist/handlers/empty-trash.js +217 -0
- package/dist/handlers/flag-push.d.ts +38 -0
- package/dist/handlers/flag-push.js +195 -0
- package/dist/handlers/imported-folder.d.ts +4 -0
- package/dist/handlers/imported-folder.js +23 -0
- package/dist/handlers/mailbox-management.d.ts +6 -0
- package/dist/handlers/mailbox-management.js +294 -0
- package/dist/handlers/mailbox-sync-order.d.ts +21 -0
- package/dist/handlers/mailbox-sync-order.js +50 -0
- package/dist/handlers/message-copy.d.ts +40 -0
- package/dist/handlers/message-copy.js +371 -0
- package/dist/handlers/message-delete-terminal.d.ts +60 -0
- package/dist/handlers/message-delete-terminal.js +77 -0
- package/dist/handlers/message-delete.d.ts +57 -0
- package/dist/handlers/message-delete.js +520 -0
- package/{src/handlers/message-move-terminal.ts → dist/handlers/message-move-terminal.d.ts} +18 -105
- package/dist/handlers/message-move-terminal.js +94 -0
- package/dist/handlers/message-move.d.ts +146 -0
- package/dist/handlers/message-move.js +490 -0
- package/dist/handlers/placement-move-push.d.ts +62 -0
- package/dist/handlers/placement-move-push.js +307 -0
- package/dist/handlers/probe-message-id.d.ts +2 -0
- package/dist/handlers/probe-message-id.js +6 -0
- package/dist/handlers/sync-mailboxes.d.ts +45 -0
- package/dist/handlers/sync-mailboxes.js +233 -0
- package/dist/handlers/sync-message-body.d.ts +23 -0
- package/dist/handlers/sync-message-body.js +182 -0
- package/dist/handlers/sync-messages.d.ts +29 -0
- package/dist/handlers/sync-messages.js +285 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +67 -0
- package/dist/lambda-context.d.ts +8 -0
- package/dist/lambda-context.js +33 -0
- package/dist/mailbox-row.d.ts +9 -0
- package/dist/mailbox-row.js +6 -0
- package/dist/poller.d.ts +1 -0
- package/dist/poller.js +51 -0
- package/dist/process.d.ts +6 -0
- package/dist/process.js +3 -0
- package/dist/processor.d.ts +3 -0
- package/dist/processor.js +44 -0
- package/dist/queues.d.ts +10 -0
- package/dist/queues.js +1 -0
- package/dist/s3-account-objects.d.ts +3 -0
- package/dist/s3-account-objects.js +26 -0
- package/dist/scheduler/attachment-sweep.d.ts +11 -0
- package/dist/scheduler/attachment-sweep.js +23 -0
- package/dist/scheduler/calendar-subscriptions.d.ts +26 -0
- package/dist/scheduler/calendar-subscriptions.js +35 -0
- package/dist/scheduler/config.d.ts +27 -0
- package/{src/scheduler/config.ts → dist/scheduler/config.js} +7 -26
- package/{src/scheduler/decide-due.ts → dist/scheduler/decide-due.d.ts} +3 -12
- package/dist/scheduler/decide-due.js +17 -0
- package/dist/scheduler/handler.d.ts +21 -0
- package/{src/scheduler/handler.ts → dist/scheduler/handler.js} +25 -42
- package/{src/scheduler/loop.ts → dist/scheduler/loop.d.ts} +7 -23
- package/dist/scheduler/loop.js +8 -0
- package/dist/scheduler/run-tick.d.ts +62 -0
- package/dist/scheduler/run-tick.js +164 -0
- package/dist/scheduler/runner.d.ts +2 -0
- package/{src/scheduler/runner.ts → dist/scheduler/runner.js} +57 -78
- package/dist/scheduler/stranded-placement-redrive.d.ts +16 -0
- package/dist/scheduler/stranded-placement-redrive.js +172 -0
- package/dist/sqs-queues.d.ts +4 -0
- package/dist/sqs-queues.js +22 -0
- package/dist/with-oauth-lifecycle-deps.d.ts +5 -0
- package/dist/with-oauth-lifecycle-deps.js +17 -0
- package/dist/with-oauth-lifecycle.d.ts +56 -0
- package/dist/with-oauth-lifecycle.js +92 -0
- package/{src/worker-version.ts → dist/worker-version.d.ts} +1 -2
- package/dist/worker-version.js +12 -0
- package/package.json +75 -69
- package/src/account-check.test.ts +0 -149
- package/src/account-check.ts +0 -101
- package/src/body-sync-gate.test.ts +0 -185
- package/src/body-sync-gate.ts +0 -86
- package/src/cli.ts +0 -218
- package/src/connection-scope.test.ts +0 -266
- package/src/connection-scope.ts +0 -314
- package/src/credential-less-sync-fence.test.ts +0 -152
- package/src/e2e-processor-shim.ts +0 -232
- package/src/emit.test.ts +0 -145
- package/src/emit.ts +0 -115
- package/src/events.ts +0 -252
- package/src/failure-kind.test.ts +0 -42
- package/src/handlers/append-sent-message.sqlite.test.ts +0 -176
- package/src/handlers/append-sent-message.ts +0 -264
- package/src/handlers/delete-account-objects.test.ts +0 -126
- package/src/handlers/delete-account-objects.ts +0 -96
- package/src/handlers/empty-trash.sqlite.test.ts +0 -209
- package/src/handlers/empty-trash.ts +0 -352
- package/src/handlers/flag-push.ts +0 -285
- package/src/handlers/imported-folder.test.ts +0 -63
- package/src/handlers/imported-folder.ts +0 -38
- package/src/handlers/mailbox-management.sqlite.test.ts +0 -265
- package/src/handlers/mailbox-management.ts +0 -486
- package/src/handlers/mailbox-sync-order.test.ts +0 -93
- package/src/handlers/mailbox-sync-order.ts +0 -65
- package/src/handlers/message-copy.sqlite.test.ts +0 -298
- package/src/handlers/message-copy.ts +0 -569
- package/src/handlers/message-delete-terminal.ts +0 -147
- package/src/handlers/message-delete.sqlite.test.ts +0 -291
- package/src/handlers/message-delete.ts +0 -888
- package/src/handlers/message-move.sqlite.test.ts +0 -271
- package/src/handlers/message-move.test.ts +0 -216
- package/src/handlers/message-move.ts +0 -755
- package/src/handlers/placement-move-push.test.ts +0 -137
- package/src/handlers/placement-move-push.ts +0 -478
- package/src/handlers/placement-push.sqlite.test.ts +0 -299
- package/src/handlers/probe-message-id.sqlite.test.ts +0 -192
- package/src/handlers/probe-message-id.ts +0 -15
- package/src/handlers/sync-mailboxes.test.ts +0 -183
- package/src/handlers/sync-mailboxes.ts +0 -425
- package/src/handlers/sync-message-body.sqlite.test.ts +0 -314
- package/src/handlers/sync-message-body.test.ts +0 -150
- package/src/handlers/sync-message-body.ts +0 -366
- package/src/handlers/sync-messages.sqlite.test.ts +0 -195
- package/src/handlers/sync-messages.ts +0 -537
- package/src/index.test.ts +0 -22
- package/src/index.ts +0 -82
- package/src/mail-sync-selection.sqlite.test.ts +0 -227
- package/src/mailbox-row.ts +0 -20
- package/src/pending-sync.sqlite.test.ts +0 -165
- package/src/poller.ts +0 -55
- package/src/processor.ts +0 -62
- package/src/queues.ts +0 -40
- package/src/scheduler/attachment-sweep.sqlite.test.ts +0 -74
- package/src/scheduler/attachment-sweep.ts +0 -46
- package/src/scheduler/calendar-subscriptions.sqlite.test.ts +0 -147
- package/src/scheduler/calendar-subscriptions.ts +0 -103
- package/src/scheduler/config.test.ts +0 -40
- package/src/scheduler/decide-due.test.ts +0 -44
- package/src/scheduler/half-deleted-accounts.sqlite.test.ts +0 -196
- package/src/scheduler/loop.test.ts +0 -143
- package/src/scheduler/run-tick.sqlite.test.ts +0 -191
- package/src/scheduler/run-tick.ts +0 -281
- package/src/scheduler/stranded-placement-redrive.ts +0 -265
- package/src/stranded-placement.sqlite.test.ts +0 -799
- package/src/test-helpers/test-world.ts +0 -425
- package/src/test-helpers/worker-queues.ts +0 -11
- package/src/thread-join.sqlite.test.ts +0 -106
- package/src/with-oauth-lifecycle-deps.sqlite.test.ts +0 -269
- package/src/with-oauth-lifecycle-deps.ts +0 -71
- package/src/with-oauth-lifecycle.ts +0 -158
- package/tsconfig.json +0 -8
|
@@ -1,352 +0,0 @@
|
|
|
1
|
-
import { getClient } from "@remit/backend/client";
|
|
2
|
-
import type { MessageItem } from "@remit/data-ports";
|
|
3
|
-
import { trashMailboxAt } from "@remit/data-ports/folder-role";
|
|
4
|
-
import { isAcceptedSchemaVersion } from "@remit/data-ports/mutation-events";
|
|
5
|
-
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
6
|
-
import type { Logger } from "@remit/logger";
|
|
7
|
-
import {
|
|
8
|
-
buildThreadMessageUndelete,
|
|
9
|
-
carriesForeignUid,
|
|
10
|
-
guardConnectionCursor,
|
|
11
|
-
isCursorRebuildNeeded,
|
|
12
|
-
MailboxCursorPausedError,
|
|
13
|
-
} from "@remit/mailbox-service";
|
|
14
|
-
import { isAccountDeleted } from "../account-check.js";
|
|
15
|
-
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
16
|
-
import type { EmptyTrashEvent } from "../events.js";
|
|
17
|
-
import { findMailboxRow } from "../mailbox-row.js";
|
|
18
|
-
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
19
|
-
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Handle EMPTY_TRASH events.
|
|
23
|
-
* Permanently deletes all messages in the Trash mailbox.
|
|
24
|
-
*
|
|
25
|
-
* The only unrecoverable mutation reader issues, so the folder's identity is
|
|
26
|
-
* confirmed here, on the connection that does the expunging: the role still
|
|
27
|
-
* names this mailbox on confirmed evidence, and the UIDVALIDITY the SELECT
|
|
28
|
-
* serves still matches the one the user consented to. Every refusal reverts
|
|
29
|
-
* the optimistic local marks and acks — a throw would stall the account's FIFO
|
|
30
|
-
* group behind an event no retry can fix (issues #287, #289, #290).
|
|
31
|
-
*/
|
|
32
|
-
export const handleEmptyTrash = async (
|
|
33
|
-
event: EmptyTrashEvent,
|
|
34
|
-
log: Logger,
|
|
35
|
-
createConnectionScope = createConnectionScopeWithCredentials,
|
|
36
|
-
): Promise<void> => {
|
|
37
|
-
const {
|
|
38
|
-
account: accountService,
|
|
39
|
-
message: messageService,
|
|
40
|
-
threadMessage: threadMessageService,
|
|
41
|
-
mailbox: mailboxService,
|
|
42
|
-
mailboxSpecialUse: mailboxSpecialUseService,
|
|
43
|
-
secrets,
|
|
44
|
-
} = await getClient();
|
|
45
|
-
|
|
46
|
-
const { accountId, trashMailboxId } = event;
|
|
47
|
-
|
|
48
|
-
log.info({ event: event.type, accountId, trashMailboxId }, "Handling event");
|
|
49
|
-
|
|
50
|
-
const account = await accountService.get(accountId);
|
|
51
|
-
if (!account) {
|
|
52
|
-
throw new Error(`Account ${accountId} not found`);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
if (isAccountDeleted(account, log)) {
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// `emptyTrash` marks every row in the folder `deleting` + `isDeleted` before
|
|
60
|
-
// enqueueing, so any row this expunge does not carry through is hidden from
|
|
61
|
-
// every listing with nothing else to unhide it: no sync clears it, a repeat
|
|
62
|
-
// Empty Trash skips it again, and listings filter `isDeleted`. Handing it
|
|
63
|
-
// back is right whichever way it got left behind — mail already gone from
|
|
64
|
-
// the server becomes visible and the next cursor rebuild reconciles it, and
|
|
65
|
-
// an unsettled move is rewritten by its own MESSAGE_DELETE. Not `failed`:
|
|
66
|
-
// the mail is intact, and saying otherwise about a whole folder is a lie.
|
|
67
|
-
const handBackMarkedRows = async (
|
|
68
|
-
rows: readonly Pick<
|
|
69
|
-
MessageItem,
|
|
70
|
-
"messageId" | "status" | "mailboxId" | "uid"
|
|
71
|
-
>[],
|
|
72
|
-
): Promise<number> => {
|
|
73
|
-
let revertedCount = 0;
|
|
74
|
-
for (const message of rows) {
|
|
75
|
-
if (message.status !== MessageStatus.deleting) continue;
|
|
76
|
-
|
|
77
|
-
// Empty Trash only flips `isDeleted` on the listing rows; a permanent
|
|
78
|
-
// delete removes them up front. So a `deleting` row with no listing row
|
|
79
|
-
// was marked by some other in-flight operation, and reverting the
|
|
80
|
-
// Message under it would resurrect what that one is about to remove.
|
|
81
|
-
const threadMessages = await threadMessageService.findAllByMessageId(
|
|
82
|
-
account.accountConfigId,
|
|
83
|
-
message.messageId,
|
|
84
|
-
);
|
|
85
|
-
if (threadMessages.length === 0) continue;
|
|
86
|
-
|
|
87
|
-
// The hand-back is a transition off the row as this sweep read it
|
|
88
|
-
// (imap-mutations R3), because the snapshot it walks holds nothing:
|
|
89
|
-
// between the listing and here another lane can settle the row onto a
|
|
90
|
-
// placement of its own, and writing `active` + `synced` over that would
|
|
91
|
-
// declare a mutation settled that nobody confirmed.
|
|
92
|
-
const restored = await messageService.transitionPlacement(
|
|
93
|
-
message.messageId,
|
|
94
|
-
{
|
|
95
|
-
status: MessageStatus.deleting,
|
|
96
|
-
mailboxId: message.mailboxId,
|
|
97
|
-
uid: message.uid,
|
|
98
|
-
},
|
|
99
|
-
{
|
|
100
|
-
status: MessageStatus.active,
|
|
101
|
-
syncStatus: MessageSyncStatus.synced,
|
|
102
|
-
},
|
|
103
|
-
);
|
|
104
|
-
if (!restored) {
|
|
105
|
-
log.info(
|
|
106
|
-
{ accountId, messageId: message.messageId },
|
|
107
|
-
"Empty Trash left a row alone: its placement changed after the sweep read it",
|
|
108
|
-
);
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
for (const threadMessage of threadMessages) {
|
|
112
|
-
const args = buildThreadMessageUndelete(threadMessage);
|
|
113
|
-
await threadMessageService.update(
|
|
114
|
-
threadMessage.accountConfigId,
|
|
115
|
-
threadMessage.threadMessageId,
|
|
116
|
-
args.set,
|
|
117
|
-
{ composites: args.composites },
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
revertedCount += 1;
|
|
121
|
-
}
|
|
122
|
-
return revertedCount;
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
const abandonEmptyTrash = async (
|
|
126
|
-
reason: string,
|
|
127
|
-
alert: string,
|
|
128
|
-
context: Record<string, unknown> = {},
|
|
129
|
-
): Promise<void> => {
|
|
130
|
-
log.error({ alert, accountId, trashMailboxId, ...context }, reason);
|
|
131
|
-
await handBackMarkedRows(
|
|
132
|
-
await messageService.listAllByMailbox(trashMailboxId),
|
|
133
|
-
);
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
if (!isAcceptedSchemaVersion(event.schemaVersion)) {
|
|
137
|
-
await abandonEmptyTrash(
|
|
138
|
-
"Refused to empty trash: event was minted under an unknown contract",
|
|
139
|
-
"empty_trash_unknown_schema_version",
|
|
140
|
-
{ schemaVersion: event.schemaVersion },
|
|
141
|
-
);
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// The role can be re-appointed between consent and this run, and a queued
|
|
146
|
-
// event then expunges the folder the user just stopped using as Trash.
|
|
147
|
-
// Confirmed evidence only: what the user appointed, or what the server
|
|
148
|
-
// flagged.
|
|
149
|
-
const resolution = await mailboxSpecialUseService.resolveTrashRole(accountId);
|
|
150
|
-
const trashGate = trashMailboxAt(resolution, "confirmed");
|
|
151
|
-
if (!trashGate.allowed || trashGate.mailbox.mailboxId !== trashMailboxId) {
|
|
152
|
-
await abandonEmptyTrash(
|
|
153
|
-
"Refused to empty trash: this account's Trash is no longer that folder",
|
|
154
|
-
"empty_trash_role_moved",
|
|
155
|
-
{
|
|
156
|
-
resolvedKind: resolution.kind,
|
|
157
|
-
resolvedMailboxId: trashGate.allowed
|
|
158
|
-
? trashGate.mailbox.mailboxId
|
|
159
|
-
: undefined,
|
|
160
|
-
},
|
|
161
|
-
);
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
await withOAuthLifecycle(
|
|
166
|
-
buildLifecycleDeps(secrets, accountService),
|
|
167
|
-
account,
|
|
168
|
-
log,
|
|
169
|
-
async (credentials) => {
|
|
170
|
-
// The Trash folder can be deleted between enqueue and this sync, leaving a
|
|
171
|
-
// queued event pointing at a gone row. The lookup then throws
|
|
172
|
-
// NotFoundError forever, and on the account's per-group FIFO that head
|
|
173
|
-
// message stalls the whole pipeline (issues #287, #289, #290). A deleted
|
|
174
|
-
// Trash makes the empty moot: ack with a WARN.
|
|
175
|
-
const trash = await findMailboxRow(
|
|
176
|
-
mailboxService,
|
|
177
|
-
accountId,
|
|
178
|
-
trashMailboxId,
|
|
179
|
-
);
|
|
180
|
-
if (trash.kind === "gone") {
|
|
181
|
-
log.warn(
|
|
182
|
-
{ accountId, mailboxId: trashMailboxId },
|
|
183
|
-
"Skipping EMPTY_TRASH: mailbox no longer exists (deleted)",
|
|
184
|
-
);
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
const mailbox = trash.mailbox;
|
|
188
|
-
|
|
189
|
-
// A paused cursor is not a wait here: this return acks the event and
|
|
190
|
-
// nothing re-issues it, so the marks have to come back or the folder
|
|
191
|
-
// stays hidden forever. The user retries.
|
|
192
|
-
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
193
|
-
await abandonEmptyTrash(
|
|
194
|
-
"Abandoned empty trash: mailbox cursor is not normal",
|
|
195
|
-
"empty_trash_cursor_paused",
|
|
196
|
-
{ cursorState: mailbox.cursorState },
|
|
197
|
-
);
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const scope = createConnectionScope(account, credentials);
|
|
202
|
-
|
|
203
|
-
await scope
|
|
204
|
-
.getConnection()
|
|
205
|
-
.then(async (rawConnection) => {
|
|
206
|
-
// Guard at the openBox choke point (epic #1281 invariants 3 & 5):
|
|
207
|
-
// a fresh mismatch trips the mailbox and throws once the SELECT
|
|
208
|
-
// reveals it.
|
|
209
|
-
const connection = guardConnectionCursor(
|
|
210
|
-
rawConnection,
|
|
211
|
-
{ mailboxService },
|
|
212
|
-
accountId,
|
|
213
|
-
mailbox,
|
|
214
|
-
);
|
|
215
|
-
const boxStatus = await connection.openBox(mailbox.fullPath, false);
|
|
216
|
-
|
|
217
|
-
// The path is not the folder. A third-party client that renames
|
|
218
|
-
// Trash and creates a fresh one leaves this event pointing at a
|
|
219
|
-
// path now served by a folder the user never consented to empty;
|
|
220
|
-
// UIDVALIDITY is what tells them apart (RFC 9051 2.3.1.1).
|
|
221
|
-
if (boxStatus.uidvalidity !== event.trashUidValidity) {
|
|
222
|
-
await abandonEmptyTrash(
|
|
223
|
-
"Refused to empty trash: the folder at this path is not the one the user emptied",
|
|
224
|
-
"empty_trash_uidvalidity_mismatch",
|
|
225
|
-
{
|
|
226
|
-
servedUidValidity: boxStatus.uidvalidity,
|
|
227
|
-
consentedUidValidity: event.trashUidValidity,
|
|
228
|
-
},
|
|
229
|
-
);
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// One read, before the expunge, decides both what is destroyed on
|
|
234
|
-
// the server and what is removed locally — so the two sets are the
|
|
235
|
-
// same set by construction and no message can be expunged that the
|
|
236
|
-
// sweep then declines to remove (issue #1230). Reading afterwards
|
|
237
|
-
// put the exclusion downstream of the destruction: a refused row
|
|
238
|
-
// had already lost its server copy, could not be refused into
|
|
239
|
-
// existence again, and nothing routine repaired it.
|
|
240
|
-
//
|
|
241
|
-
// Reconciles, never waits (imap-mutations R2). A row whose move
|
|
242
|
-
// into Trash has not settled names this folder while still
|
|
243
|
-
// carrying the SOURCE folder's uid, so matching it against the
|
|
244
|
-
// expunge answers for whatever Trash held at that uid — another
|
|
245
|
-
// message, deleted here in both its rows. Waiting is not open to
|
|
246
|
-
// this handler the way it is to the API-side mutators: a move on
|
|
247
|
-
// this account's own FIFO group cannot run until this returns, so
|
|
248
|
-
// the ceiling would be spent to reach the same answer. It buys
|
|
249
|
-
// nothing against the other lane either — PLACEMENT_MOVE_PUSH
|
|
250
|
-
// rides a standard queue with no group at all
|
|
251
|
-
// (`deploy/vps/queues.json`), so it is ordered against nothing
|
|
252
|
-
// here and can settle a row mid-sweep. That is why the writes
|
|
253
|
-
// below are transitions rather than snapshot writes (R3).
|
|
254
|
-
//
|
|
255
|
-
// `carriesForeignUid`, not the placement binding: the binding reads
|
|
256
|
-
// `status`, and `status` is exactly what an operation marking this
|
|
257
|
-
// folder overwrites. This is the same predicate `emptyTrash`
|
|
258
|
-
// applies before it marks anything — one gate, one answer, now
|
|
259
|
-
// asked once and honoured on both sides of the expunge.
|
|
260
|
-
const localMessages =
|
|
261
|
-
await messageService.listAllByMailbox(trashMailboxId);
|
|
262
|
-
const sweepable = localMessages.filter(
|
|
263
|
-
(message) => !carriesForeignUid(message),
|
|
264
|
-
);
|
|
265
|
-
|
|
266
|
-
// A refused row spares its uid only where no row the sweep will
|
|
267
|
-
// carry claims the same number. The two cases the predicate cannot
|
|
268
|
-
// tell apart part here: a row genuinely mid-move borrows a uid that
|
|
269
|
-
// belongs to a different message in this folder, and sparing that
|
|
270
|
-
// number would strand the message that owns it; a settled row whose
|
|
271
|
-
// `originalUid` a pre-#1217 build left behind owns its uid outright,
|
|
272
|
-
// and expunging it destroys mail the sweep then refuses to remove.
|
|
273
|
-
const sweepableUids = new Set(
|
|
274
|
-
sweepable.map((message) => message.uid),
|
|
275
|
-
);
|
|
276
|
-
const refusedUids = new Set(
|
|
277
|
-
localMessages
|
|
278
|
-
.filter(
|
|
279
|
-
(message) =>
|
|
280
|
-
carriesForeignUid(message) && !sweepableUids.has(message.uid),
|
|
281
|
-
)
|
|
282
|
-
.map((message) => message.uid),
|
|
283
|
-
);
|
|
284
|
-
|
|
285
|
-
const uids = (await connection.search(["ALL"])).filter(
|
|
286
|
-
(uid) => !refusedUids.has(uid),
|
|
287
|
-
);
|
|
288
|
-
|
|
289
|
-
if (uids.length > 0) {
|
|
290
|
-
await connection.deleteMessages(uids);
|
|
291
|
-
log.info(
|
|
292
|
-
{ count: uids.length, refused: refusedUids.size },
|
|
293
|
-
"Deleted messages from IMAP trash",
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// What was expunged is a fact this connection observed, and only
|
|
298
|
-
// those rows go.
|
|
299
|
-
const expunged = new Set(uids);
|
|
300
|
-
const swept = sweepable.filter((message) =>
|
|
301
|
-
expunged.has(message.uid),
|
|
302
|
-
);
|
|
303
|
-
const sweptIds = new Set(swept.map((message) => message.messageId));
|
|
304
|
-
const deletedCount = swept.length;
|
|
305
|
-
|
|
306
|
-
for (const message of swept) {
|
|
307
|
-
await messageService.delete(message.messageId);
|
|
308
|
-
|
|
309
|
-
const threadMessage = await threadMessageService.findByMessageId(
|
|
310
|
-
account.accountConfigId,
|
|
311
|
-
message.messageId,
|
|
312
|
-
);
|
|
313
|
-
if (threadMessage) {
|
|
314
|
-
await threadMessageService.delete(
|
|
315
|
-
threadMessage.accountConfigId,
|
|
316
|
-
threadMessage.threadMessageId,
|
|
317
|
-
);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Everything this sweep did not carry through is still marked for a
|
|
322
|
-
// deletion that will never come: mail another client already
|
|
323
|
-
// emptied, mail that reached Trash after the SEARCH, a redelivery
|
|
324
|
-
// finishing a partial sweep, and a row whose uid the sweep would
|
|
325
|
-
// not bind. All of them must come back rather than sit invisible —
|
|
326
|
-
// the last one especially, since nothing else is coming to clear
|
|
327
|
-
// its mark, and a row hidden in a folder it never left is worse
|
|
328
|
-
// than one the next sync re-projects.
|
|
329
|
-
const revertedCount = await handBackMarkedRows(
|
|
330
|
-
localMessages.filter((message) => !sweptIds.has(message.messageId)),
|
|
331
|
-
);
|
|
332
|
-
|
|
333
|
-
log.info(
|
|
334
|
-
{ accountId, deletedCount, revertedCount },
|
|
335
|
-
"Trash emptied successfully",
|
|
336
|
-
);
|
|
337
|
-
})
|
|
338
|
-
.catch(async (error: unknown) => {
|
|
339
|
-
if (error instanceof MailboxCursorPausedError) {
|
|
340
|
-
await abandonEmptyTrash(
|
|
341
|
-
"Abandoned empty trash: mailbox cursor is not normal",
|
|
342
|
-
"empty_trash_cursor_paused",
|
|
343
|
-
{ cursorState: error.state },
|
|
344
|
-
);
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
throw error;
|
|
348
|
-
})
|
|
349
|
-
.finally(() => scope.disconnect());
|
|
350
|
-
},
|
|
351
|
-
);
|
|
352
|
-
};
|
|
@@ -1,285 +0,0 @@
|
|
|
1
|
-
import { getClient } from "@remit/backend/client";
|
|
2
|
-
import { isNotFoundError } from "@remit/data-ports/errors";
|
|
3
|
-
import type { Logger } from "@remit/logger";
|
|
4
|
-
import { recordImapFailure } from "@remit/logger-lambda/metrics";
|
|
5
|
-
import {
|
|
6
|
-
guardConnectionCursor,
|
|
7
|
-
isCursorRebuildNeeded,
|
|
8
|
-
isPlacementUnsettled,
|
|
9
|
-
MailboxCursorPausedError,
|
|
10
|
-
resolveExhaustedFlagPushFailure,
|
|
11
|
-
} from "@remit/mailbox-service";
|
|
12
|
-
import { attemptBudget } from "@remit/sqs-client/attempt-budget";
|
|
13
|
-
import { isAccountDeleted } from "../account-check.js";
|
|
14
|
-
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
15
|
-
import type { FlagPushEvent } from "../events.js";
|
|
16
|
-
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
17
|
-
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
18
|
-
|
|
19
|
-
export const getFlagPushMaxAttempts = (
|
|
20
|
-
processEnv: NodeJS.ProcessEnv = process.env,
|
|
21
|
-
): number => attemptBudget("FLAG_PUSH_MAX_ATTEMPTS", 3, processEnv);
|
|
22
|
-
|
|
23
|
-
export const FLAG_PUSH_MAX_ATTEMPTS = getFlagPushMaxAttempts();
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* How long a marker may sit deferred behind a move before it is dropped
|
|
27
|
-
* outright. A move that settles takes seconds to low minutes; one stuck past
|
|
28
|
-
* this window has almost certainly already exhausted its own retries and been
|
|
29
|
-
* resolved as broken by its own terminal resolver, so deferring further would
|
|
30
|
-
* cycle one SQS round trip per sync tick forever instead of surfacing the
|
|
31
|
-
* stall.
|
|
32
|
-
*/
|
|
33
|
-
export const FLAG_PUSH_DEFER_MAX_MS = 10 * 60 * 1000;
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Handle FLAG_PUSH events (issue #1273, epic #1281). Drains ONE pending
|
|
37
|
-
* flag-push marker: resolves the message's UID and CURRENT mailbox fresh
|
|
38
|
-
* from the Message row (never a value captured at enqueue — invariant 1),
|
|
39
|
-
* pushes the IMAP STORE (add or remove, per the marker's `operation`), and
|
|
40
|
-
* clears the marker ONLY on confirmed success.
|
|
41
|
-
*
|
|
42
|
-
* Precedence (epic invariant 2):
|
|
43
|
-
* - While pending, resync never reverts the flag — nothing in this handler
|
|
44
|
-
* (or anywhere else) reads flags FROM IMAP back into `MessageFlag`/
|
|
45
|
-
* `ThreadMessage` for an existing row; the only flag-state writes happen
|
|
46
|
-
* here (confirmed push) or in `FlagQueueService` (the user's own local
|
|
47
|
-
* flip, which already applied before this marker existed).
|
|
48
|
-
* - A later flip of the SAME field already replaced this marker (`put`) by
|
|
49
|
-
* the time this event is processed, OR advanced it past `pending` — either
|
|
50
|
-
* way `markerService.find` returns the CURRENT marker, so this handler
|
|
51
|
-
* always drives the freshest intent, never a stale one.
|
|
52
|
-
* - An external delete supersedes the marker entirely — handled by
|
|
53
|
-
* `resolveExhaustedFlagPushFailure`'s `reconciled` outcome.
|
|
54
|
-
*
|
|
55
|
-
* Cursor-guarded (#1272, epic #1281 invariant 5): the connection is wrapped
|
|
56
|
-
* via `guardConnectionCursor` around the mailbox's current `openBox` choke
|
|
57
|
-
* point, so no stored UID touches the server while the mailbox's axis is
|
|
58
|
-
* being rebuilt. A trip pauses the push (routine, no alarm) — the marker
|
|
59
|
-
* stays durable and pushes again on the next event or sync tick.
|
|
60
|
-
*/
|
|
61
|
-
export const handleFlagPush = async (
|
|
62
|
-
event: FlagPushEvent,
|
|
63
|
-
log: Logger,
|
|
64
|
-
receiveCount = 1,
|
|
65
|
-
createConnectionScope = createConnectionScopeWithCredentials,
|
|
66
|
-
): Promise<void> => {
|
|
67
|
-
const {
|
|
68
|
-
account: accountService,
|
|
69
|
-
mailbox: mailboxService,
|
|
70
|
-
message: messageService,
|
|
71
|
-
threadMessage: threadMessageService,
|
|
72
|
-
flagPush: markerService,
|
|
73
|
-
secrets,
|
|
74
|
-
} = await getClient();
|
|
75
|
-
|
|
76
|
-
const { accountId, accountConfigId, messageId, flagName } = event;
|
|
77
|
-
|
|
78
|
-
const marker = await markerService.find(messageId, flagName);
|
|
79
|
-
if (!marker) {
|
|
80
|
-
log.info(
|
|
81
|
-
{ messageId, flagName, accountId },
|
|
82
|
-
"No pending flag-push marker (already confirmed or superseded); nothing to push",
|
|
83
|
-
);
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const account = await accountService.get(accountId);
|
|
88
|
-
if (!account) {
|
|
89
|
-
throw new Error(`Account ${accountId} not found`);
|
|
90
|
-
}
|
|
91
|
-
if (isAccountDeleted(account, log)) {
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const [message] = await messageService.get([messageId]);
|
|
96
|
-
|
|
97
|
-
// The message row is already gone — some other reconciliation path (body
|
|
98
|
-
// sync, placement move, a prior flag-push exhaustion) already deleted it.
|
|
99
|
-
// The marker is orphaned; drop it without touching IMAP.
|
|
100
|
-
if (!message) {
|
|
101
|
-
await markerService.delete(messageId, flagName);
|
|
102
|
-
log.info(
|
|
103
|
-
{ messageId, flagName, accountId },
|
|
104
|
-
"Message row no longer exists; flag-push marker dropped without pushing",
|
|
105
|
-
);
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// The message carries a move still in flight — `MessageMoveService`'s or
|
|
110
|
-
// `PlacementMoveService`'s local optimistic write, not yet confirmed by the
|
|
111
|
-
// server. A STORE resolved against this row addresses the destination
|
|
112
|
-
// folder at the SOURCE folder's uid: a different message (issue #496). This
|
|
113
|
-
// is the wait half of the wait-or-reconcile decision
|
|
114
|
-
// (docs/architecture/imap-mutations.md R2) — the push blocks until the move
|
|
115
|
-
// it depends on settles.
|
|
116
|
-
if (isPlacementUnsettled(message)) {
|
|
117
|
-
const deferredForMs = Date.now() - marker.createdAt;
|
|
118
|
-
|
|
119
|
-
if (deferredForMs > FLAG_PUSH_DEFER_MAX_MS) {
|
|
120
|
-
// The move this push was waiting on never settled within any
|
|
121
|
-
// reasonable window — deferring further would cycle one SQS round
|
|
122
|
-
// trip per sync tick forever. Drop the stale marker loudly rather
|
|
123
|
-
// than push against state nobody has confirmed.
|
|
124
|
-
await markerService.delete(messageId, flagName);
|
|
125
|
-
recordImapFailure("FLAG_PUSH_MOVE_NEVER_SETTLED", "other");
|
|
126
|
-
log.error(
|
|
127
|
-
{
|
|
128
|
-
alert: "flag_push_move_never_settled",
|
|
129
|
-
messageId,
|
|
130
|
-
flagName,
|
|
131
|
-
accountId,
|
|
132
|
-
deferredForMs,
|
|
133
|
-
},
|
|
134
|
-
"Message move never settled; dropping the flag-push marker that was waiting on it",
|
|
135
|
-
);
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Reset to `pending` rather than advancing: `drainPendingFlagPushes`
|
|
140
|
-
// only re-arms markers in that state, scoped by the marker's own
|
|
141
|
-
// `mailboxId` (the push destination), so the next periodic sync tick
|
|
142
|
-
// of that mailbox picks this back up once the move has settled.
|
|
143
|
-
await markerService.updateState(messageId, flagName, "pending");
|
|
144
|
-
log.info(
|
|
145
|
-
{ messageId, flagName, accountId, deferredForMs },
|
|
146
|
-
"Message has a move in flight; pausing outbound flag push until it settles",
|
|
147
|
-
);
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// The worker has picked up the event and is about to actually attempt the
|
|
152
|
-
// IMAP STORE — advance the state engine (pending/queued -> processing).
|
|
153
|
-
// Idempotent to call again on a redelivered event (a prior attempt that
|
|
154
|
-
// died mid-flight already left it here).
|
|
155
|
-
await markerService.updateState(messageId, flagName, "processing");
|
|
156
|
-
|
|
157
|
-
// The folder can be deleted between enqueue and this push, leaving a marker
|
|
158
|
-
// pointing at a gone row. The lookup then throws NotFoundError forever, and
|
|
159
|
-
// on the account's per-group FIFO that head message stalls the whole pipeline
|
|
160
|
-
// (issues #287, #289, #290). The push is moot — drop the orphaned marker (as
|
|
161
|
-
// the message-gone branch above already does) and ack with a WARN.
|
|
162
|
-
const mailbox = await mailboxService
|
|
163
|
-
.get(accountId, message.mailboxId)
|
|
164
|
-
.catch((error: unknown) => {
|
|
165
|
-
if (isNotFoundError(error)) return null;
|
|
166
|
-
throw error;
|
|
167
|
-
});
|
|
168
|
-
if (!mailbox) {
|
|
169
|
-
await markerService.delete(messageId, flagName);
|
|
170
|
-
log.warn(
|
|
171
|
-
{ messageId, flagName, accountId, mailboxId: message.mailboxId },
|
|
172
|
-
"Skipping FLAG_PUSH: mailbox no longer exists (deleted); marker dropped",
|
|
173
|
-
);
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
178
|
-
// paused never even borrows a connection. Optimization only — the
|
|
179
|
-
// guardConnectionCursor wrap below is the structural guarantee (#1272).
|
|
180
|
-
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
181
|
-
log.info(
|
|
182
|
-
{ messageId, flagName, accountId, cursorState: mailbox.cursorState },
|
|
183
|
-
"Mailbox cursor not normal; pausing outbound flag push this round",
|
|
184
|
-
);
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
await withOAuthLifecycle(
|
|
189
|
-
buildLifecycleDeps(secrets, accountService),
|
|
190
|
-
account,
|
|
191
|
-
log,
|
|
192
|
-
async (credentials) => {
|
|
193
|
-
const scope = createConnectionScope(account, credentials);
|
|
194
|
-
|
|
195
|
-
await scope
|
|
196
|
-
.getConnection()
|
|
197
|
-
.then(async (rawConnection) => {
|
|
198
|
-
const connection = guardConnectionCursor(
|
|
199
|
-
rawConnection,
|
|
200
|
-
{ mailboxService },
|
|
201
|
-
accountId,
|
|
202
|
-
mailbox,
|
|
203
|
-
);
|
|
204
|
-
|
|
205
|
-
await connection.openBox(mailbox.fullPath, false);
|
|
206
|
-
|
|
207
|
-
if (marker.operation === "add") {
|
|
208
|
-
await connection.addFlags([message.uid], [flagName]);
|
|
209
|
-
} else {
|
|
210
|
-
await connection.removeFlags([message.uid], [flagName]);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Confirmed IMAP acknowledgement — clears ONLY here, never on
|
|
214
|
-
// attempt (the defect issue #1273 fixes).
|
|
215
|
-
await markerService.delete(messageId, flagName);
|
|
216
|
-
|
|
217
|
-
log.info(
|
|
218
|
-
{
|
|
219
|
-
messageId,
|
|
220
|
-
flagName,
|
|
221
|
-
accountId,
|
|
222
|
-
operation: marker.operation,
|
|
223
|
-
uid: message.uid,
|
|
224
|
-
mailboxPath: mailbox.fullPath,
|
|
225
|
-
},
|
|
226
|
-
"Flag push confirmed on IMAP; marker cleared",
|
|
227
|
-
);
|
|
228
|
-
})
|
|
229
|
-
.catch(async (error: unknown) => {
|
|
230
|
-
// Expected pause (epic #1281 invariant 3), not a fault: ack and
|
|
231
|
-
// skip rather than propagating into queue retry/DLQ. The marker
|
|
232
|
-
// stays durable; the push resumes once the mailbox returns to
|
|
233
|
-
// normal.
|
|
234
|
-
if (error instanceof MailboxCursorPausedError) {
|
|
235
|
-
log.info(
|
|
236
|
-
{ messageId, flagName, accountId, cursorState: error.state },
|
|
237
|
-
"UIDVALIDITY changed; mailbox cursor tripped, pausing outbound flag push",
|
|
238
|
-
);
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (receiveCount < FLAG_PUSH_MAX_ATTEMPTS) {
|
|
243
|
-
// Transient push failure — expected (connections drop). No
|
|
244
|
-
// alarm; queue redelivery retries from the still-durable marker.
|
|
245
|
-
throw error;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Redelivery budget exhausted: resolve into exactly one of the
|
|
249
|
-
// two terminal outcomes (epic invariant 3) instead of
|
|
250
|
-
// dead-lettering with no diagnosis.
|
|
251
|
-
const { outcome } = await resolveExhaustedFlagPushFailure(
|
|
252
|
-
{ markerService, messageService, log },
|
|
253
|
-
{
|
|
254
|
-
placement: {
|
|
255
|
-
status: message.status,
|
|
256
|
-
mailboxId: message.mailboxId,
|
|
257
|
-
uid: message.uid,
|
|
258
|
-
},
|
|
259
|
-
accountId,
|
|
260
|
-
accountConfigId,
|
|
261
|
-
messageId,
|
|
262
|
-
flagName,
|
|
263
|
-
uid: message.uid,
|
|
264
|
-
mailboxPath: mailbox.fullPath,
|
|
265
|
-
getConnection: scope.getConnection,
|
|
266
|
-
},
|
|
267
|
-
);
|
|
268
|
-
|
|
269
|
-
if (outcome === "reconciled") {
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Terminal and never re-thrown, so the handler-outcome series
|
|
274
|
-
// records this record as a success. Counted here or it is invisible.
|
|
275
|
-
recordImapFailure("FLAG_PUSH_EXHAUSTED", "other");
|
|
276
|
-
log.error(
|
|
277
|
-
{ error: error instanceof Error ? error.message : String(error) },
|
|
278
|
-
"Flag push retry exhausted; message still exists at its mailbox",
|
|
279
|
-
);
|
|
280
|
-
// Terminal — never re-thrown, so the caller acks either way.
|
|
281
|
-
})
|
|
282
|
-
.finally(() => scope.disconnect());
|
|
283
|
-
},
|
|
284
|
-
);
|
|
285
|
-
};
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import type { MailboxItem } from "@remit/data-ports";
|
|
4
|
-
import type { CreateMailboxQueueInput } from "@remit/mailbox-service";
|
|
5
|
-
import { createImportedFolder } from "./imported-folder.js";
|
|
6
|
-
|
|
7
|
-
const inbox = {
|
|
8
|
-
mailboxId: "mbx-inbox",
|
|
9
|
-
accountId: "acc-1",
|
|
10
|
-
fullPath: "INBOX",
|
|
11
|
-
namespaceType: "personal",
|
|
12
|
-
namespacePrefix: "INBOX.",
|
|
13
|
-
hierarchyDelimiter: ".",
|
|
14
|
-
} as MailboxItem;
|
|
15
|
-
|
|
16
|
-
describe("createImportedFolder (#1103)", () => {
|
|
17
|
-
it("creates the folder through the pending folder-create path, in the INBOX namespace", async () => {
|
|
18
|
-
const calls: [CreateMailboxQueueInput, string, boolean | undefined][] = [];
|
|
19
|
-
const create = createImportedFolder(
|
|
20
|
-
{ findByPath: async () => inbox },
|
|
21
|
-
{
|
|
22
|
-
createMailbox: async (input, accountId, subscribe) => {
|
|
23
|
-
calls.push([input, accountId, subscribe]);
|
|
24
|
-
return {
|
|
25
|
-
outcome: "Created" as const,
|
|
26
|
-
mailbox: {
|
|
27
|
-
...inbox,
|
|
28
|
-
mailboxId: "mbx-new",
|
|
29
|
-
fullPath: input.fullPath,
|
|
30
|
-
},
|
|
31
|
-
};
|
|
32
|
-
},
|
|
33
|
-
},
|
|
34
|
-
);
|
|
35
|
-
|
|
36
|
-
const created = await create("acc-1", "INBOX.Facturen");
|
|
37
|
-
|
|
38
|
-
assert.equal(
|
|
39
|
-
created.outcome === "Created" && created.mailbox.mailboxId,
|
|
40
|
-
"mbx-new",
|
|
41
|
-
);
|
|
42
|
-
assert.equal(calls.length, 1);
|
|
43
|
-
const [input, accountId, subscribe] = calls[0];
|
|
44
|
-
assert.equal(accountId, "acc-1");
|
|
45
|
-
assert.equal(subscribe, true);
|
|
46
|
-
assert.equal(input.fullPath, "INBOX.Facturen");
|
|
47
|
-
assert.equal(input.namespacePrefix, "INBOX.");
|
|
48
|
-
assert.equal(input.hierarchyDelimiter, ".");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("refuses to guess a namespace for an account with no INBOX", async () => {
|
|
52
|
-
const create = createImportedFolder(
|
|
53
|
-
{ findByPath: async () => null },
|
|
54
|
-
{
|
|
55
|
-
createMailbox: async () => {
|
|
56
|
-
throw new Error("must not be called");
|
|
57
|
-
},
|
|
58
|
-
},
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
await assert.rejects(create("acc-1", "Facturen"), /no INBOX/);
|
|
62
|
-
});
|
|
63
|
-
});
|