@remit/backend 0.0.119 → 0.0.121
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/package.json +5 -1
- package/src/handlers/filter.ts +24 -0
- package/src/handlers/folder-role-labels.ts +63 -0
- package/src/handlers/folder-role.test.ts +100 -75
- package/src/handlers/folder-role.ts +1 -28
- package/src/handlers/mailbox-bindings.ts +111 -0
- package/src/handlers/mailbox-rename-refusal.test.ts +247 -0
- package/src/handlers/mailbox-settled-target.test.ts +239 -0
- package/src/handlers/mailbox.test.ts +164 -30
- package/src/handlers/mailbox.ts +163 -84
- package/src/handlers/message.ts +11 -2
package/src/handlers/mailbox.ts
CHANGED
|
@@ -3,12 +3,14 @@ import type {
|
|
|
3
3
|
RenameMailboxInput,
|
|
4
4
|
} from "@remit/api-openapi-types";
|
|
5
5
|
import type { IAccountSettingRepository, MailboxItem } from "@remit/data-ports";
|
|
6
|
-
import { ForbiddenError, NotFoundError } from "@remit/data-ports/errors";
|
|
7
6
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
BadRequestError,
|
|
8
|
+
ForbiddenError,
|
|
9
|
+
MailboxNotSettledError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
} from "@remit/data-ports/errors";
|
|
12
|
+
import { isReservedFolderName } from "@remit/data-ports/folder-role";
|
|
13
|
+
import { mailboxLeafName } from "@remit/data-ports/mailbox-name";
|
|
12
14
|
import { MailboxSyncStatus, MessageSystemFlag } from "@remit/domain-enums";
|
|
13
15
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
14
16
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
@@ -30,7 +32,7 @@ import {
|
|
|
30
32
|
type MailboxOverrides,
|
|
31
33
|
} from "./account-overrides.js";
|
|
32
34
|
import { assertAccountOwnership } from "./account-ownership.js";
|
|
33
|
-
import {
|
|
35
|
+
import { assertNoBindings, loadMailboxBindings } from "./mailbox-bindings.js";
|
|
34
36
|
|
|
35
37
|
/**
|
|
36
38
|
* The mute flag and the display-name override are user preferences that live
|
|
@@ -66,6 +68,7 @@ export const pickMailboxOverrideChanges = (
|
|
|
66
68
|
export interface MailboxPatchClient {
|
|
67
69
|
mailbox: {
|
|
68
70
|
get(accountId: string, mailboxId: string): Promise<MailboxItem>;
|
|
71
|
+
listAllByAccount(accountId: string): Promise<MailboxItem[]>;
|
|
69
72
|
};
|
|
70
73
|
mailboxQueue: {
|
|
71
74
|
renameMailbox(
|
|
@@ -73,53 +76,80 @@ export interface MailboxPatchClient {
|
|
|
73
76
|
newPath: string,
|
|
74
77
|
accountId: string,
|
|
75
78
|
): Promise<MailboxItem>;
|
|
79
|
+
dismissMailboxIntent(
|
|
80
|
+
mailboxId: string,
|
|
81
|
+
accountId: string,
|
|
82
|
+
): Promise<MailboxItem>;
|
|
76
83
|
};
|
|
77
84
|
accountSetting: Pick<IAccountSettingRepository, "get" | "upsert" | "delete">;
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
/**
|
|
81
|
-
*
|
|
82
|
-
* but the paths recorded beside them (#887) would still name where the folders
|
|
83
|
-
* were before. Move the labels with the branch, or a later third-party delete
|
|
84
|
-
* names a path the user has not seen since the rename.
|
|
88
|
+
* Refuse a rename that no retry of the same request could ever satisfy (D4).
|
|
85
89
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
90
|
+
* `INBOX` is refused at both ends: renaming it moves its mail to the new name
|
|
91
|
+
* and leaves an empty INBOX behind, and renaming anything *to* it collides with
|
|
92
|
+
* the one name RFC 3501 reserves (D5).
|
|
93
|
+
*
|
|
94
|
+
* A reserved leaf name is refused for a different reason. The mailbox sweep
|
|
95
|
+
* reads a folder whose leaf name is a role's conventional name but which lacks
|
|
96
|
+
* the server's flag as a duplicate, and deletes its row — under D8, with the
|
|
97
|
+
* folder's mail — as soon as another folder holds the flag. A rename to such a
|
|
98
|
+
* name settles `synced` and is then reaped. Refusing at the API is the same
|
|
99
|
+
* class of check as the rest of D4 and does not require teaching that heuristic
|
|
100
|
+
* about renames.
|
|
89
101
|
*/
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
accountConfigId: string,
|
|
102
|
+
const assertRenameTargetAllowed = async (
|
|
103
|
+
client: Pick<MailboxPatchClient, "mailbox">,
|
|
93
104
|
accountId: string,
|
|
94
|
-
|
|
95
|
-
|
|
105
|
+
before: MailboxItem,
|
|
106
|
+
target: string,
|
|
96
107
|
): Promise<void> => {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
108
|
+
if (before.fullPath.toUpperCase() === "INBOX") {
|
|
109
|
+
throw new BadRequestError("The inbox can't be renamed.");
|
|
110
|
+
}
|
|
111
|
+
if (target.trim().length === 0) {
|
|
112
|
+
throw new BadRequestError("A folder needs a name.");
|
|
113
|
+
}
|
|
114
|
+
if (target.toUpperCase() === "INBOX") {
|
|
115
|
+
throw new BadRequestError("“INBOX” is reserved for the inbox.");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const leaf = mailboxLeafName({
|
|
119
|
+
fullPath: target,
|
|
120
|
+
hierarchyDelimiter: before.hierarchyDelimiter,
|
|
121
|
+
});
|
|
122
|
+
if (isReservedFolderName(leaf)) {
|
|
123
|
+
throw new BadRequestError(
|
|
124
|
+
`“${leaf}” is reserved for a system folder. Pick another name.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A path a rename is on its way to is as taken as one a folder already sits
|
|
129
|
+
// at (D2). Reading `fullPath` alone accepts a second rename onto a target
|
|
130
|
+
// another is already recorded for: both settle, both write the same path,
|
|
131
|
+
// and the sweep then reaps one row — with its mail — and inserts a duplicate
|
|
132
|
+
// for the survivor's path. The account's folders are few and the sweep
|
|
133
|
+
// already reads them all, so one pass answers both questions.
|
|
134
|
+
const folders = await client.mailbox.listAllByAccount(accountId);
|
|
135
|
+
for (const folder of folders) {
|
|
136
|
+
if (folder.mailboxId === before.mailboxId) continue;
|
|
137
|
+
if (folder.fullPath === target) {
|
|
138
|
+
throw new BadRequestError(`A folder named “${target}” is already there.`);
|
|
139
|
+
}
|
|
140
|
+
// `pending` only. A failed rename keeps its target so the client can name
|
|
141
|
+
// what it was aiming at and offer a retry (T6), and nothing clears it
|
|
142
|
+
// until the user retries or dismisses — so honouring a `failed` row's
|
|
143
|
+
// claim would tell everyone else the path is taken by a rename that is
|
|
144
|
+
// not happening, with no way to find that out and nothing to wait for.
|
|
145
|
+
if (
|
|
146
|
+
folder.syncStatus === MailboxSyncStatus.pending &&
|
|
147
|
+
folder.pendingPath === target
|
|
148
|
+
) {
|
|
149
|
+
throw new BadRequestError(
|
|
150
|
+
`“${folder.fullPath}” is already being renamed to “${target}”.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
123
153
|
}
|
|
124
154
|
};
|
|
125
155
|
|
|
@@ -130,6 +160,11 @@ const refreshAppointmentLabels = async (
|
|
|
130
160
|
* (the recorded subtree intent + MAILBOX_RENAME event) — only when `fullPath`
|
|
131
161
|
* is present. An override-only PATCH therefore never calls
|
|
132
162
|
* `mailboxQueue.renameMailbox`.
|
|
163
|
+
*
|
|
164
|
+
* A `fullPath` equal to the row's confirmed one is not a rename: it is the
|
|
165
|
+
* dismissal of a failed intent (T10, T11), which is how a user keeps the name
|
|
166
|
+
* the folder already has without new API surface. It is checked first, because
|
|
167
|
+
* from `failed` it is the one route out that enqueues nothing.
|
|
133
168
|
*/
|
|
134
169
|
export const applyMailboxPatch = async (
|
|
135
170
|
client: MailboxPatchClient,
|
|
@@ -161,26 +196,17 @@ export const applyMailboxPatch = async (
|
|
|
161
196
|
return client.mailbox.get(accountId, mailboxId);
|
|
162
197
|
}
|
|
163
198
|
|
|
164
|
-
// Read before the rename: the labels of the folders under this one are
|
|
165
|
-
// rebased off the path it is leaving, which the row no longer carries after.
|
|
166
199
|
const before = await client.mailbox.get(accountId, mailboxId);
|
|
167
|
-
|
|
168
|
-
mailboxId,
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
);
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
mailboxId,
|
|
178
|
-
oldPath: before.fullPath,
|
|
179
|
-
newPath: renamed.fullPath,
|
|
180
|
-
},
|
|
181
|
-
before.hierarchyDelimiter,
|
|
182
|
-
);
|
|
183
|
-
return renamed;
|
|
200
|
+
if (fullPath === before.fullPath) {
|
|
201
|
+
return client.mailboxQueue.dismissMailboxIntent(mailboxId, accountId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await assertRenameTargetAllowed(client, accountId, before, fullPath);
|
|
205
|
+
|
|
206
|
+
// The row keeps the path the server holds until the rename settles (D2), so
|
|
207
|
+
// nothing that names a folder by path moves here — the appointment labels
|
|
208
|
+
// recorded beside each role (#887) included. They move with the settle.
|
|
209
|
+
return client.mailboxQueue.renameMailbox(mailboxId, fullPath, accountId);
|
|
184
210
|
};
|
|
185
211
|
|
|
186
212
|
/**
|
|
@@ -203,6 +229,51 @@ export const assertMailboxInAccount = (
|
|
|
203
229
|
throw new ForbiddenError(`Mailbox ${mailbox.mailboxId} not in account`);
|
|
204
230
|
};
|
|
205
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Why a folder cannot be bound to yet, per state — one wording in one place,
|
|
234
|
+
* because the remedy differs and the client has only the sentence to show.
|
|
235
|
+
*/
|
|
236
|
+
const UNSETTLED_REASON: Record<string, (path: string) => string> = {
|
|
237
|
+
[MailboxSyncStatus.pending]: (path) =>
|
|
238
|
+
`“${path}” isn’t ready yet — the mail server hasn’t confirmed it.`,
|
|
239
|
+
[MailboxSyncStatus.deleting]: (path) => `“${path}” is being deleted.`,
|
|
240
|
+
[MailboxSyncStatus.failed]: (path) =>
|
|
241
|
+
`The last change to “${path}” failed. Retry or dismiss it first.`,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Every API write that binds a durable reference to a mailbox requires that
|
|
246
|
+
* mailbox to be `synced` (D12, first row; imap-mutations R2: wait). A dangling
|
|
247
|
+
* reference is permanent; blocking costs seconds.
|
|
248
|
+
*
|
|
249
|
+
* This is the server-side floor under the client-side wait, and it is not
|
|
250
|
+
* redundant with it: there is more than one client, and a script hitting the
|
|
251
|
+
* API directly gets no wait at all.
|
|
252
|
+
*
|
|
253
|
+
* It is the other half of D16. A binding is only ever created against a settled
|
|
254
|
+
* folder (here) and only ever removed by the user (the delete refusal below),
|
|
255
|
+
* so a `mailboxId` that names nothing never exists — which is what lets every
|
|
256
|
+
* reader of a filter's `actionMailboxId` or a role appointment treat the
|
|
257
|
+
* reference as resolvable, with no missing-target branch.
|
|
258
|
+
*
|
|
259
|
+
* A `\Noselect` container is refused by construction — mailbox-sync keeps no
|
|
260
|
+
* row for one — so there is nothing to bind to. Clearing a reference is not a
|
|
261
|
+
* bind and is never gated, and neither is a move *out of* a folder: a
|
|
262
|
+
* `deleting` folder still holds its mail until the DELETE lands, which is the
|
|
263
|
+
* delete-with-move flow (D13).
|
|
264
|
+
*/
|
|
265
|
+
export const assertMailboxSettled = (
|
|
266
|
+
target: Pick<MailboxItem, "mailboxId" | "fullPath" | "syncStatus">,
|
|
267
|
+
): void => {
|
|
268
|
+
const reason = UNSETTLED_REASON[target.syncStatus];
|
|
269
|
+
if (!reason) return;
|
|
270
|
+
throw new MailboxNotSettledError(
|
|
271
|
+
reason(target.fullPath),
|
|
272
|
+
target.mailboxId,
|
|
273
|
+
target.syncStatus,
|
|
274
|
+
);
|
|
275
|
+
};
|
|
276
|
+
|
|
206
277
|
/**
|
|
207
278
|
* Every pending placement move (issue #1271) for an account, on whichever
|
|
208
279
|
* backend is active (`RemitClient.placementMove` is present on both — see
|
|
@@ -262,24 +333,6 @@ const toMailboxResponse = (
|
|
|
262
333
|
updatedAt: mailbox.updatedAt,
|
|
263
334
|
});
|
|
264
335
|
|
|
265
|
-
/**
|
|
266
|
-
* Drop folders the user has deleted but the imap-worker has not yet reaped.
|
|
267
|
-
*
|
|
268
|
-
* A delete is a soft delete: the row lives on with syncStatus=deleting until the
|
|
269
|
-
* worker confirms the IMAP delete and removes it (mailbox-queue.deleteMailbox).
|
|
270
|
-
* The list is the settings view a client refetches right after confirming, so a
|
|
271
|
-
* still-`deleting` row here would keep a deleted folder on screen until the
|
|
272
|
-
* worker finishes. Hiding it makes the folder leave the list the moment the
|
|
273
|
-
* delete is confirmed; a delete that fails restores the row off `deleting`,
|
|
274
|
-
* which brings the folder back on the next read.
|
|
275
|
-
*/
|
|
276
|
-
export const excludeDeletingMailboxes = (
|
|
277
|
-
mailboxes: readonly MailboxItem[],
|
|
278
|
-
): MailboxItem[] =>
|
|
279
|
-
mailboxes.filter(
|
|
280
|
-
(mailbox) => mailbox.syncStatus !== MailboxSyncStatus.deleting,
|
|
281
|
-
);
|
|
282
|
-
|
|
283
336
|
export const MailboxOperations: Record<
|
|
284
337
|
MailboxOperationIds,
|
|
285
338
|
OperationHandler<MailboxOperationIds>
|
|
@@ -300,8 +353,6 @@ export const MailboxOperations: Record<
|
|
|
300
353
|
continuationToken,
|
|
301
354
|
});
|
|
302
355
|
|
|
303
|
-
const visibleItems = excludeDeletingMailboxes(result.items);
|
|
304
|
-
|
|
305
356
|
// Overrides (mute / display-name / role) live in per-mailbox AccountSetting
|
|
306
357
|
// rows (RFC 032). Load the whole config's set in one query and key it by
|
|
307
358
|
// mailboxId so each mailbox surfaces its overrides without an N+1.
|
|
@@ -318,8 +369,11 @@ export const MailboxOperations: Record<
|
|
|
318
369
|
client,
|
|
319
370
|
accountId,
|
|
320
371
|
);
|
|
372
|
+
// A folder mid-mutation is listed, labelled (D11). Hiding a `deleting` one
|
|
373
|
+
// made a delete that does not settle look like a folder that silently
|
|
374
|
+
// vanished, while the server still held it.
|
|
321
375
|
const items = applyPendingMoveCountPrediction(
|
|
322
|
-
|
|
376
|
+
result.items,
|
|
323
377
|
pendingMoves,
|
|
324
378
|
pendingUnseenFlagPushes,
|
|
325
379
|
);
|
|
@@ -468,6 +522,31 @@ export const MailboxDetailOperations: Record<
|
|
|
468
522
|
const mailbox = await client.mailbox.get(accountId, mailboxId);
|
|
469
523
|
assertMailboxInAccount(mailbox, accountId, "act");
|
|
470
524
|
|
|
525
|
+
// Cheapest first (D4, D13). None of the three becomes possible by
|
|
526
|
+
// retrying the same request, so all three are 400 and all three run
|
|
527
|
+
// before anything is recorded.
|
|
528
|
+
if (mailbox.fullPath.toUpperCase() === "INBOX") {
|
|
529
|
+
throw new BadRequestError("The inbox can't be deleted.");
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const children = await client.mailbox.findByPathPrefix(
|
|
533
|
+
accountId,
|
|
534
|
+
mailbox.fullPath,
|
|
535
|
+
mailbox.hierarchyDelimiter,
|
|
536
|
+
);
|
|
537
|
+
if (children.length > 0) {
|
|
538
|
+
// The server would keep the parent's name as a placeholder and leave the
|
|
539
|
+
// children untouched — an outcome no local row describes (D9).
|
|
540
|
+
throw new BadRequestError(
|
|
541
|
+
`“${mailbox.fullPath}” has folders inside it. Delete those first.`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
assertNoBindings(
|
|
546
|
+
mailbox,
|
|
547
|
+
await loadMailboxBindings(client, accountConfigId, accountId, mailboxId),
|
|
548
|
+
);
|
|
549
|
+
|
|
471
550
|
await client.mailboxQueue.deleteMailbox(mailboxId, accountId);
|
|
472
551
|
return { statusCode: 204 };
|
|
473
552
|
},
|
package/src/handlers/message.ts
CHANGED
|
@@ -50,6 +50,7 @@ import type {
|
|
|
50
50
|
MessageOperationIds,
|
|
51
51
|
OperationHandler,
|
|
52
52
|
} from "../types.js";
|
|
53
|
+
import { assertMailboxSettled } from "./mailbox.js";
|
|
53
54
|
|
|
54
55
|
type StarColorValue = (typeof StarColor)[keyof typeof StarColor];
|
|
55
56
|
|
|
@@ -939,7 +940,10 @@ export const MessageBulkOperations: Record<
|
|
|
939
940
|
"act",
|
|
940
941
|
);
|
|
941
942
|
|
|
942
|
-
// Destination must belong to the same (caller-owned) account
|
|
943
|
+
// Destination must belong to the same (caller-owned) account, and must be
|
|
944
|
+
// a folder the mail server has settled: the row records the destination
|
|
945
|
+
// before the move confirms, so binding to a folder that may never exist
|
|
946
|
+
// leaves the message pointing at nothing (D12, first row).
|
|
943
947
|
const destination = await client.mailbox.get(
|
|
944
948
|
accountId,
|
|
945
949
|
destinationMailboxId,
|
|
@@ -949,6 +953,7 @@ export const MessageBulkOperations: Record<
|
|
|
949
953
|
`Destination mailbox ${destinationMailboxId} not in account`,
|
|
950
954
|
);
|
|
951
955
|
}
|
|
956
|
+
assertMailboxSettled(destination);
|
|
952
957
|
|
|
953
958
|
// MessageMoveService handles: Message + ThreadMessage updates + SQS events
|
|
954
959
|
await client.messageMove.moveMessages(
|
|
@@ -985,7 +990,10 @@ export const MessageBulkOperations: Record<
|
|
|
985
990
|
"act",
|
|
986
991
|
);
|
|
987
992
|
|
|
988
|
-
// Destination must belong to the same (caller-owned) account
|
|
993
|
+
// Destination must belong to the same (caller-owned) account, and must be
|
|
994
|
+
// a folder the mail server has settled: the row records the destination
|
|
995
|
+
// before the move confirms, so binding to a folder that may never exist
|
|
996
|
+
// leaves the message pointing at nothing (D12, first row).
|
|
989
997
|
const destination = await client.mailbox.get(
|
|
990
998
|
accountId,
|
|
991
999
|
destinationMailboxId,
|
|
@@ -995,6 +1003,7 @@ export const MessageBulkOperations: Record<
|
|
|
995
1003
|
`Destination mailbox ${destinationMailboxId} not in account`,
|
|
996
1004
|
);
|
|
997
1005
|
}
|
|
1006
|
+
assertMailboxSettled(destination);
|
|
998
1007
|
|
|
999
1008
|
// MessageMoveService handles: Message copies + ThreadMessage creation + SQS events
|
|
1000
1009
|
await client.messageMove.copyMessages(
|