@remit/backend 0.0.120 → 0.0.122
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 +1 -1
- package/src/handlers/filter.ts +24 -0
- package/src/handlers/folder-role.test.ts +33 -6
- package/src/handlers/folder-role.ts +1 -28
- package/src/handlers/mailbox-bindings.ts +111 -0
- package/src/handlers/mailbox-settled-target.test.ts +239 -0
- package/src/handlers/mailbox.test.ts +164 -30
- package/src/handlers/mailbox.ts +76 -21
- package/src/handlers/message.ts +11 -2
- package/src/index.test.ts +83 -0
- package/src/index.ts +30 -3
- package/src/jwt-auth.test.ts +38 -4
- package/src/jwt-auth.ts +3 -5
package/package.json
CHANGED
package/src/handlers/filter.ts
CHANGED
|
@@ -5,8 +5,10 @@ import type {
|
|
|
5
5
|
} from "@remit/api-openapi-types";
|
|
6
6
|
import {
|
|
7
7
|
deriveFilterTtl,
|
|
8
|
+
FILTER_NO_ACTION,
|
|
8
9
|
type FilterItem,
|
|
9
10
|
type IFilterAnchorTransaction,
|
|
11
|
+
type IMailboxRepository,
|
|
10
12
|
type UpdateFilterInput,
|
|
11
13
|
} from "@remit/data-ports";
|
|
12
14
|
import { BadRequestError } from "@remit/data-ports/errors";
|
|
@@ -22,6 +24,7 @@ import type {
|
|
|
22
24
|
OperationHandler,
|
|
23
25
|
} from "../types.js";
|
|
24
26
|
import { assertAccountOwnership } from "./account-ownership.js";
|
|
27
|
+
import { assertMailboxInAccount, assertMailboxSettled } from "./mailbox.js";
|
|
25
28
|
|
|
26
29
|
/**
|
|
27
30
|
* Minimal filter-service surface the create handler needs — declared as a
|
|
@@ -222,6 +225,25 @@ export const createFilterWithAnchor = async (
|
|
|
222
225
|
);
|
|
223
226
|
};
|
|
224
227
|
|
|
228
|
+
/**
|
|
229
|
+
* A filter's `actionMailboxId` is a durable reference, so its target has to be
|
|
230
|
+
* a folder the mail server has settled (D12, first row) — 422 otherwise. The
|
|
231
|
+
* sentinel means "no move action" and binds to nothing, so it is not gated,
|
|
232
|
+
* and neither is a filter that touches the field at all.
|
|
233
|
+
*/
|
|
234
|
+
const assertActionMailboxSettled = async (
|
|
235
|
+
client: { mailbox: Pick<IMailboxRepository, "get"> },
|
|
236
|
+
accountId: string,
|
|
237
|
+
actionMailboxId: string | undefined,
|
|
238
|
+
): Promise<void> => {
|
|
239
|
+
if (actionMailboxId === undefined || actionMailboxId === FILTER_NO_ACTION) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const target = await client.mailbox.get(accountId, actionMailboxId);
|
|
243
|
+
assertMailboxInAccount(target, accountId, "act");
|
|
244
|
+
assertMailboxSettled(target);
|
|
245
|
+
};
|
|
246
|
+
|
|
225
247
|
export const FilterOperations: Record<
|
|
226
248
|
FilterOperationIds,
|
|
227
249
|
OperationHandler<FilterOperationIds>
|
|
@@ -265,6 +287,7 @@ export const FilterOperations: Record<
|
|
|
265
287
|
const client = await getClient();
|
|
266
288
|
const account = await client.account.get(accountId);
|
|
267
289
|
assertAccountOwnership(account, accountConfigId, "act");
|
|
290
|
+
await assertActionMailboxSettled(client, accountId, input.actionMailboxId);
|
|
268
291
|
|
|
269
292
|
const filter = await createFilterWithAnchor(
|
|
270
293
|
{
|
|
@@ -316,6 +339,7 @@ export const FilterDetailOperations: Record<
|
|
|
316
339
|
|
|
317
340
|
const { filter } = client;
|
|
318
341
|
const patch = pickFilterUpdate(body as Partial<UpdateFilterRequestBody>);
|
|
342
|
+
await assertActionMailboxSettled(client, accountId, patch.actionMailboxId);
|
|
319
343
|
const touchesScopeOrExpiry =
|
|
320
344
|
Object.hasOwn(patch, "scope") || Object.hasOwn(patch, "expiresAt");
|
|
321
345
|
const resolvedPatch: Partial<UpdateFilterInput> = touchesScopeOrExpiry
|
|
@@ -4,8 +4,8 @@ import type { AccountSettingItem, MailboxItem } from "@remit/data-ports";
|
|
|
4
4
|
import { isPublicApiError } from "@remit/data-ports/errors";
|
|
5
5
|
import { composeFolderRoleAppointmentLabelName } from "@remit/data-ports/folder-role";
|
|
6
6
|
import { CanonicalMailboxRole, MailboxSyncStatus } from "@remit/domain-enums";
|
|
7
|
-
import { assertMailboxSettled } from "./folder-role.js";
|
|
8
7
|
import { refreshFolderAppointmentLabels } from "./folder-role-labels.js";
|
|
8
|
+
import { assertMailboxSettled } from "./mailbox.js";
|
|
9
9
|
|
|
10
10
|
const mailbox = (over: Partial<MailboxItem>): MailboxItem =>
|
|
11
11
|
({
|
|
@@ -57,14 +57,41 @@ describe("assertMailboxSettled", () => {
|
|
|
57
57
|
});
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
it("
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
it("refuses a folder whose last change failed", () => {
|
|
61
|
+
// `failed` means the folder exists at `fullPath` and the last rename or
|
|
62
|
+
// delete did not land, so it is exactly as unready to be bound to as a
|
|
63
|
+
// folder mid-mutation — and the user has a retry or a dismissal to make
|
|
64
|
+
// before anything should point at it.
|
|
65
|
+
const error = caught(() =>
|
|
66
|
+
assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.failed })),
|
|
63
67
|
);
|
|
68
|
+
assert.equal(publicErrorOf(error)?.code, "mailbox_not_settled");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("answers 422, not 409", () => {
|
|
72
|
+
// 409 already means "this folder is being changed right now, refresh".
|
|
73
|
+
// This means "the folder you are pointing at has not settled yet", which
|
|
74
|
+
// is different copy and a different remedy (D4).
|
|
75
|
+
const error = caught(() =>
|
|
76
|
+
assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.pending })),
|
|
77
|
+
);
|
|
78
|
+
assert.equal((error as { statusCode?: number }).statusCode, 422);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("words each state differently, because each has its own remedy", () => {
|
|
82
|
+
const messageFor = (syncStatus: MailboxItem["syncStatus"]) =>
|
|
83
|
+
(caught(() => assertMailboxSettled(mailbox({ syncStatus }))) as Error)
|
|
84
|
+
.message;
|
|
85
|
+
|
|
86
|
+
assert.match(messageFor(MailboxSyncStatus.pending), /hasn.t confirmed it/);
|
|
87
|
+
assert.match(messageFor(MailboxSyncStatus.deleting), /being deleted/);
|
|
88
|
+
assert.match(messageFor(MailboxSyncStatus.failed), /Retry or dismiss/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("allows a settled folder", () => {
|
|
64
92
|
assert.doesNotThrow(() =>
|
|
65
|
-
assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.
|
|
93
|
+
assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.synced })),
|
|
66
94
|
);
|
|
67
|
-
assert.doesNotThrow(() => assertMailboxSettled(mailbox({})));
|
|
68
95
|
});
|
|
69
96
|
});
|
|
70
97
|
|
|
@@ -2,9 +2,6 @@ import type {
|
|
|
2
2
|
AppointFolderRoleInput,
|
|
3
3
|
CanonicalMailboxRole,
|
|
4
4
|
} from "@remit/api-openapi-types";
|
|
5
|
-
import type { MailboxItem } from "@remit/data-ports";
|
|
6
|
-
import { MailboxNotSettledError } from "@remit/data-ports/errors";
|
|
7
|
-
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
8
5
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
9
6
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
10
7
|
import { getClient } from "../service/data-client.js";
|
|
@@ -17,31 +14,7 @@ import {
|
|
|
17
14
|
resolveAccountFolderAppointments,
|
|
18
15
|
writeFolderRoleAppointment,
|
|
19
16
|
} from "./folder-role-appointments.js";
|
|
20
|
-
import { assertMailboxInAccount } from "./mailbox.js";
|
|
21
|
-
|
|
22
|
-
/** Mailbox states in which the mail server has not settled the folder yet. */
|
|
23
|
-
const UNSETTLED: ReadonlySet<string> = new Set([
|
|
24
|
-
MailboxSyncStatus.pending,
|
|
25
|
-
MailboxSyncStatus.deleting,
|
|
26
|
-
]);
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Appointing a role to a folder the mail server is still creating or deleting
|
|
30
|
-
* would bind the account's Trash to a folder that may never exist (D16 item 3,
|
|
31
|
-
* imap-mutations R2: wait). A `\Noselect` container is refused by construction
|
|
32
|
-
* — mailbox-sync keeps no row for one — so there is nothing to appoint.
|
|
33
|
-
*/
|
|
34
|
-
export const assertMailboxSettled = (
|
|
35
|
-
target: Pick<MailboxItem, "mailboxId" | "fullPath" | "syncStatus">,
|
|
36
|
-
): void => {
|
|
37
|
-
const syncStatus = target.syncStatus;
|
|
38
|
-
if (!syncStatus || !UNSETTLED.has(syncStatus)) return;
|
|
39
|
-
throw new MailboxNotSettledError(
|
|
40
|
-
`Mailbox ${target.fullPath} is not settled on the mail server yet`,
|
|
41
|
-
target.mailboxId,
|
|
42
|
-
syncStatus,
|
|
43
|
-
);
|
|
44
|
-
};
|
|
17
|
+
import { assertMailboxInAccount, assertMailboxSettled } from "./mailbox.js";
|
|
45
18
|
|
|
46
19
|
/**
|
|
47
20
|
* RFC 032 exclusive-folder-appointment (#976): the single write operation for
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FilterItem,
|
|
3
|
+
IAccountSettingRepository,
|
|
4
|
+
IFilterRepository,
|
|
5
|
+
MailboxItem,
|
|
6
|
+
} from "@remit/data-ports";
|
|
7
|
+
import { FILTER_NO_ACTION } from "@remit/data-ports";
|
|
8
|
+
import { BadRequestError } from "@remit/data-ports/errors";
|
|
9
|
+
import type { CanonicalMailboxRoleValue } from "@remit/data-ports/folder-role";
|
|
10
|
+
import { loadFolderAppointmentsForAccount } from "./folder-role-appointments.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What a folder is bound to: the filters that move mail into it, and the
|
|
14
|
+
* canonical roles it has been appointed to.
|
|
15
|
+
*/
|
|
16
|
+
export interface MailboxBindings {
|
|
17
|
+
filterNames: string[];
|
|
18
|
+
roles: CanonicalMailboxRoleValue[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `filter.actionMailboxId` has no reverse index, so the filters are listed for
|
|
23
|
+
* the account config and matched — they are few and per-config, so this is not
|
|
24
|
+
* a mailbox scan. Role appointments are `account_setting` rows whose *value* is
|
|
25
|
+
* a mailboxId, which a `mailbox_id` column scan cannot find, so the existing
|
|
26
|
+
* per-account appointment map answers for them.
|
|
27
|
+
*/
|
|
28
|
+
export const loadMailboxBindings = async (
|
|
29
|
+
client: {
|
|
30
|
+
filter: Pick<IFilterRepository, "listByAccountConfig">;
|
|
31
|
+
accountSetting: Pick<IAccountSettingRepository, "get">;
|
|
32
|
+
},
|
|
33
|
+
accountConfigId: string,
|
|
34
|
+
accountId: string,
|
|
35
|
+
mailboxId: string,
|
|
36
|
+
): Promise<MailboxBindings> => {
|
|
37
|
+
const [filters, appointments] = await Promise.all([
|
|
38
|
+
client.filter.listByAccountConfig(accountConfigId),
|
|
39
|
+
loadFolderAppointmentsForAccount(
|
|
40
|
+
client.accountSetting,
|
|
41
|
+
accountConfigId,
|
|
42
|
+
accountId,
|
|
43
|
+
),
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const filterNames = filters
|
|
47
|
+
.filter(
|
|
48
|
+
(filter: FilterItem) =>
|
|
49
|
+
filter.actionMailboxId !== FILTER_NO_ACTION &&
|
|
50
|
+
filter.actionMailboxId === mailboxId,
|
|
51
|
+
)
|
|
52
|
+
.map((filter: FilterItem) => filter.name);
|
|
53
|
+
|
|
54
|
+
const roles: CanonicalMailboxRoleValue[] = [];
|
|
55
|
+
for (const [role, appointment] of appointments) {
|
|
56
|
+
if (appointment.mailboxId === mailboxId) {
|
|
57
|
+
roles.push(role as CanonicalMailboxRoleValue);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { filterNames, roles };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const asList = (items: readonly string[]): string =>
|
|
65
|
+
items.length === 1
|
|
66
|
+
? items[0]
|
|
67
|
+
: `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Refuse a delete while any durable reference is bound to the folder (D16).
|
|
71
|
+
*
|
|
72
|
+
* This is what makes the settled-target rule total: a binding is only created
|
|
73
|
+
* against a settled folder and only removed by the user, so a `mailboxId` that
|
|
74
|
+
* names nothing never exists and no reader of a filter's `actionMailboxId` or a
|
|
75
|
+
* role appointment needs a missing-target branch. It removes code rather than
|
|
76
|
+
* adding a check.
|
|
77
|
+
*
|
|
78
|
+
* The alternatives both cost more than they look. `Filter` has no enabled flag
|
|
79
|
+
* and its `actionMailboxId` sentinel means "no move action", so clearing the
|
|
80
|
+
* target silently turns a move-filter into one that does something else;
|
|
81
|
+
* deleting the user's filters outright is not a decision a folder delete gets
|
|
82
|
+
* to make.
|
|
83
|
+
*
|
|
84
|
+
* The message **names** each binding rather than counting them, because a count
|
|
85
|
+
* is not actionable — and this fires on ordinary configurations, since pointing
|
|
86
|
+
* a filter at a folder is what filters are for.
|
|
87
|
+
*/
|
|
88
|
+
export const assertNoBindings = (
|
|
89
|
+
mailbox: Pick<MailboxItem, "fullPath">,
|
|
90
|
+
bindings: MailboxBindings,
|
|
91
|
+
): void => {
|
|
92
|
+
const clauses: string[] = [];
|
|
93
|
+
if (bindings.filterNames.length > 0) {
|
|
94
|
+
clauses.push(
|
|
95
|
+
`the ${bindings.filterNames.length === 1 ? "filter" : "filters"} ${asList(
|
|
96
|
+
bindings.filterNames.map((name) => `“${name}”`),
|
|
97
|
+
)}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (bindings.roles.length > 0) {
|
|
101
|
+
clauses.push(
|
|
102
|
+
`the ${asList(bindings.roles)} ${
|
|
103
|
+
bindings.roles.length === 1 ? "role" : "roles"
|
|
104
|
+
}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (clauses.length === 0) return;
|
|
108
|
+
throw new BadRequestError(
|
|
109
|
+
`“${mailbox.fullPath}” is used by ${asList(clauses)}.`,
|
|
110
|
+
);
|
|
111
|
+
};
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every API write that binds a durable reference to a folder requires that
|
|
3
|
+
* folder to be settled (D12, first row; imap-mutations R2: wait). Driven
|
|
4
|
+
* through the real handlers rather than the helper, because the claim is that
|
|
5
|
+
* each call site is wired to it — a helper that nothing calls refuses nothing.
|
|
6
|
+
*
|
|
7
|
+
* The other half of the same invariant is the delete refusal (D16), in
|
|
8
|
+
* `mailbox.test.ts`: a binding is only created against a settled folder and
|
|
9
|
+
* only removed by the user, so a `mailboxId` that names nothing never exists.
|
|
10
|
+
*/
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { afterEach, describe, it } from "node:test";
|
|
13
|
+
import type { MailboxItem } from "@remit/data-ports";
|
|
14
|
+
import { CanonicalMailboxRole, MailboxSyncStatus } from "@remit/domain-enums";
|
|
15
|
+
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
16
|
+
import type { Context } from "openapi-backend";
|
|
17
|
+
import { deriveAccountConfigId } from "../auth.js";
|
|
18
|
+
import {
|
|
19
|
+
_resetForTest,
|
|
20
|
+
type RemitClient,
|
|
21
|
+
setClient,
|
|
22
|
+
} from "../service/data-client.js";
|
|
23
|
+
import { FilterDetailOperations, FilterOperations } from "./filter.js";
|
|
24
|
+
import { FolderRoleOperations } from "./folder-role.js";
|
|
25
|
+
import { MessageBulkOperations } from "./message.js";
|
|
26
|
+
|
|
27
|
+
const SUB = "cognito-sub-365";
|
|
28
|
+
const ACCOUNT_ID = "acc-365";
|
|
29
|
+
const CONFIG_ID = deriveAccountConfigId(SUB);
|
|
30
|
+
const TARGET = "mbx-target";
|
|
31
|
+
|
|
32
|
+
const target = (syncStatus: MailboxItem["syncStatus"]): MailboxItem =>
|
|
33
|
+
({
|
|
34
|
+
mailboxId: TARGET,
|
|
35
|
+
accountId: ACCOUNT_ID,
|
|
36
|
+
fullPath: "Receipts",
|
|
37
|
+
hierarchyDelimiter: "/",
|
|
38
|
+
syncStatus,
|
|
39
|
+
}) as MailboxItem;
|
|
40
|
+
|
|
41
|
+
interface Writes {
|
|
42
|
+
filters: unknown[];
|
|
43
|
+
filterPatches: unknown[];
|
|
44
|
+
appointments: unknown[];
|
|
45
|
+
moves: unknown[];
|
|
46
|
+
copies: unknown[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const world = (syncStatus: MailboxItem["syncStatus"]): Writes => {
|
|
50
|
+
const writes: Writes = {
|
|
51
|
+
filters: [],
|
|
52
|
+
filterPatches: [],
|
|
53
|
+
appointments: [],
|
|
54
|
+
moves: [],
|
|
55
|
+
copies: [],
|
|
56
|
+
};
|
|
57
|
+
const row = target(syncStatus);
|
|
58
|
+
|
|
59
|
+
setClient({
|
|
60
|
+
account: {
|
|
61
|
+
get: async () => ({ accountId: ACCOUNT_ID, accountConfigId: CONFIG_ID }),
|
|
62
|
+
listAllByAccountConfig: async () => [{ accountId: ACCOUNT_ID }],
|
|
63
|
+
},
|
|
64
|
+
mailbox: {
|
|
65
|
+
get: async (_accountId: string, mailboxId: string | string[]) =>
|
|
66
|
+
Array.isArray(mailboxId)
|
|
67
|
+
? mailboxId.map((id) => ({ mailboxId: id, accountId: ACCOUNT_ID }))
|
|
68
|
+
: row,
|
|
69
|
+
listAllByAccount: async () => [row],
|
|
70
|
+
},
|
|
71
|
+
message: {
|
|
72
|
+
get: async () => ({ mailboxId: "mbx-source" }),
|
|
73
|
+
},
|
|
74
|
+
filter: {
|
|
75
|
+
get: async () => ({ filterId: "flt-1", scope: "Standing" }),
|
|
76
|
+
update: async (...args: unknown[]) => {
|
|
77
|
+
writes.filterPatches.push(args);
|
|
78
|
+
return { filterId: "flt-1" };
|
|
79
|
+
},
|
|
80
|
+
listByAccountConfig: async () => [],
|
|
81
|
+
},
|
|
82
|
+
filterAnchorTransaction: {
|
|
83
|
+
createWithAnchor: async (input: unknown) => {
|
|
84
|
+
writes.filters.push(input);
|
|
85
|
+
return { filterId: "flt-1" };
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
accountSetting: {
|
|
89
|
+
get: async () => null,
|
|
90
|
+
upsert: async (item: unknown) => {
|
|
91
|
+
writes.appointments.push(item);
|
|
92
|
+
return item;
|
|
93
|
+
},
|
|
94
|
+
delete: async () => undefined,
|
|
95
|
+
},
|
|
96
|
+
messageMove: {
|
|
97
|
+
moveMessages: async (...args: unknown[]) => {
|
|
98
|
+
writes.moves.push(args);
|
|
99
|
+
},
|
|
100
|
+
copyMessages: async (...args: unknown[]) => {
|
|
101
|
+
writes.copies.push(args);
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
} as unknown as RemitClient);
|
|
105
|
+
|
|
106
|
+
return writes;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const event = {
|
|
110
|
+
requestContext: { authorizer: { claims: { sub: SUB } } },
|
|
111
|
+
} as unknown as APIGatewayProxyEvent;
|
|
112
|
+
|
|
113
|
+
const call = <T>(
|
|
114
|
+
handler: unknown,
|
|
115
|
+
request: Record<string, unknown>,
|
|
116
|
+
): Promise<T> =>
|
|
117
|
+
(handler as (context: Context, event: APIGatewayProxyEvent) => Promise<T>)(
|
|
118
|
+
{ request } as unknown as Context,
|
|
119
|
+
event,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const appoint = () =>
|
|
123
|
+
call(FolderRoleOperations.FolderRoleOperations_appointFolderRole, {
|
|
124
|
+
params: { accountId: ACCOUNT_ID, role: CanonicalMailboxRole.Archive },
|
|
125
|
+
requestBody: { mailboxId: TARGET },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const createFilter = () =>
|
|
129
|
+
call(FilterOperations.FilterOperations_createFilter, {
|
|
130
|
+
params: { accountId: ACCOUNT_ID },
|
|
131
|
+
requestBody: {
|
|
132
|
+
name: "Invoices",
|
|
133
|
+
scope: "Standing",
|
|
134
|
+
actionMailboxId: TARGET,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const updateFilter = () =>
|
|
139
|
+
call(FilterDetailOperations.FilterDetailOperations_updateFilter, {
|
|
140
|
+
params: { accountId: ACCOUNT_ID, filterId: "flt-1" },
|
|
141
|
+
requestBody: { actionMailboxId: TARGET },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const moveMessages = () =>
|
|
145
|
+
call(MessageBulkOperations.MessageBulkOperations_moveMessages, {
|
|
146
|
+
params: {},
|
|
147
|
+
requestBody: { messageIds: ["msg-1"], destinationMailboxId: TARGET },
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const copyMessages = () =>
|
|
151
|
+
call(MessageBulkOperations.MessageBulkOperations_copyMessages, {
|
|
152
|
+
params: {},
|
|
153
|
+
requestBody: { messageIds: ["msg-1"], destinationMailboxId: TARGET },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const unready = (error: unknown): boolean =>
|
|
157
|
+
(error as { statusCode?: number }).statusCode === 422 &&
|
|
158
|
+
(error as { publicApiError?: { code?: string } }).publicApiError?.code ===
|
|
159
|
+
"mailbox_not_settled";
|
|
160
|
+
|
|
161
|
+
const SITES = [
|
|
162
|
+
["appointing a role", appoint, (w: Writes) => w.appointments],
|
|
163
|
+
["creating a filter", createFilter, (w: Writes) => w.filters],
|
|
164
|
+
["updating a filter", updateFilter, (w: Writes) => w.filterPatches],
|
|
165
|
+
["moving messages", moveMessages, (w: Writes) => w.moves],
|
|
166
|
+
["copying messages", copyMessages, (w: Writes) => w.copies],
|
|
167
|
+
] as const;
|
|
168
|
+
|
|
169
|
+
describe("a write that binds to a folder requires a settled folder", () => {
|
|
170
|
+
afterEach(() => {
|
|
171
|
+
_resetForTest();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
for (const [what, run, written] of SITES) {
|
|
175
|
+
for (const state of [
|
|
176
|
+
MailboxSyncStatus.pending,
|
|
177
|
+
MailboxSyncStatus.deleting,
|
|
178
|
+
MailboxSyncStatus.failed,
|
|
179
|
+
]) {
|
|
180
|
+
it(`refuses ${what} against a ${state} folder, writing nothing`, async () => {
|
|
181
|
+
const writes = world(state);
|
|
182
|
+
|
|
183
|
+
await assert.rejects(run(), unready);
|
|
184
|
+
assert.deepEqual(written(writes), []);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
it(`accepts ${what} against a settled folder`, async () => {
|
|
189
|
+
const writes = world(MailboxSyncStatus.synced);
|
|
190
|
+
|
|
191
|
+
await run();
|
|
192
|
+
assert.equal(written(writes).length > 0, true);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
it("never answers 409 from these paths", async () => {
|
|
197
|
+
// 409 belongs to the folder's own mutation losing a compare-and-set
|
|
198
|
+
// (#362, #363). Reusing it here would make "somebody else is changing this
|
|
199
|
+
// folder" and "the folder you are pointing at is not ready" the same
|
|
200
|
+
// answer, with different copy and different remedies behind it.
|
|
201
|
+
world(MailboxSyncStatus.pending);
|
|
202
|
+
|
|
203
|
+
await assert.rejects(
|
|
204
|
+
appoint(),
|
|
205
|
+
(error: unknown) => (error as { statusCode?: number }).statusCode !== 409,
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("does not gate clearing a role whose folder is on its way out", async () => {
|
|
210
|
+
const writes = world(MailboxSyncStatus.deleting);
|
|
211
|
+
|
|
212
|
+
await call(FolderRoleOperations.FolderRoleOperations_appointFolderRole, {
|
|
213
|
+
params: { accountId: ACCOUNT_ID, role: CanonicalMailboxRole.Archive },
|
|
214
|
+
requestBody: { mailboxId: null },
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
assert.deepEqual(
|
|
218
|
+
writes.appointments,
|
|
219
|
+
[],
|
|
220
|
+
"the row is deleted, not upserted",
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("does not gate a filter that applies no move action", async () => {
|
|
225
|
+
// `None` is the sentinel for "no move action", so it binds to nothing.
|
|
226
|
+
const writes = world(MailboxSyncStatus.deleting);
|
|
227
|
+
|
|
228
|
+
await call(FilterOperations.FilterOperations_createFilter, {
|
|
229
|
+
params: { accountId: ACCOUNT_ID },
|
|
230
|
+
requestBody: {
|
|
231
|
+
name: "Just a label",
|
|
232
|
+
scope: "Standing",
|
|
233
|
+
actionMailboxId: "None",
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
assert.equal(writes.filters.length, 1);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -11,36 +11,7 @@ import {
|
|
|
11
11
|
type RemitClient,
|
|
12
12
|
setClient,
|
|
13
13
|
} from "../service/data-client.js";
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
const mailbox = (
|
|
17
|
-
over: Partial<MailboxItem> & { mailboxId: string },
|
|
18
|
-
): MailboxItem =>
|
|
19
|
-
({ fullPath: over.mailboxId, ...over }) as unknown as MailboxItem;
|
|
20
|
-
|
|
21
|
-
describe("excludeDeletingMailboxes", () => {
|
|
22
|
-
it("drops a folder being deleted so it leaves the list before the worker reaps it", () => {
|
|
23
|
-
const items = [
|
|
24
|
-
mailbox({ mailboxId: "inbox", syncStatus: MailboxSyncStatus.synced }),
|
|
25
|
-
mailbox({ mailboxId: "gone", syncStatus: MailboxSyncStatus.deleting }),
|
|
26
|
-
mailbox({ mailboxId: "new", syncStatus: MailboxSyncStatus.pending }),
|
|
27
|
-
];
|
|
28
|
-
assert.deepEqual(
|
|
29
|
-
excludeDeletingMailboxes(items).map((m) => m.mailboxId),
|
|
30
|
-
["inbox", "new"],
|
|
31
|
-
);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("keeps a folder whose delete failed and was restored off `deleting`", () => {
|
|
35
|
-
const items = [
|
|
36
|
-
mailbox({ mailboxId: "restored", syncStatus: MailboxSyncStatus.failed }),
|
|
37
|
-
];
|
|
38
|
-
assert.deepEqual(
|
|
39
|
-
excludeDeletingMailboxes(items).map((m) => m.mailboxId),
|
|
40
|
-
["restored"],
|
|
41
|
-
);
|
|
42
|
-
});
|
|
43
|
-
});
|
|
14
|
+
import { MailboxDetailOperations, TrashOperations } from "./mailbox.js";
|
|
44
15
|
|
|
45
16
|
const SUB = "cognito-sub-887";
|
|
46
17
|
const ACCOUNT_ID = "acc-887";
|
|
@@ -96,3 +67,166 @@ describe("TrashOperations_emptyTrash", () => {
|
|
|
96
67
|
);
|
|
97
68
|
});
|
|
98
69
|
});
|
|
70
|
+
|
|
71
|
+
const deleteMailbox =
|
|
72
|
+
MailboxDetailOperations.MailboxDetailOperations_deleteMailbox as unknown as (
|
|
73
|
+
context: Context,
|
|
74
|
+
event: APIGatewayProxyEvent,
|
|
75
|
+
) => Promise<{ statusCode: number }>;
|
|
76
|
+
|
|
77
|
+
const folder = (over: Partial<MailboxItem> = {}): MailboxItem =>
|
|
78
|
+
({
|
|
79
|
+
mailboxId: "mbx-1",
|
|
80
|
+
accountId: ACCOUNT_ID,
|
|
81
|
+
fullPath: "Receipts",
|
|
82
|
+
hierarchyDelimiter: "/",
|
|
83
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
84
|
+
...over,
|
|
85
|
+
}) as MailboxItem;
|
|
86
|
+
|
|
87
|
+
interface DeleteWorld {
|
|
88
|
+
mailbox?: MailboxItem;
|
|
89
|
+
children?: MailboxItem[];
|
|
90
|
+
filters?: { name: string; actionMailboxId: string }[];
|
|
91
|
+
appointments?: Record<string, string>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const callDelete = (
|
|
95
|
+
world: DeleteWorld,
|
|
96
|
+
): { run: () => Promise<{ statusCode: number }>; deleted: string[] } => {
|
|
97
|
+
const deleted: string[] = [];
|
|
98
|
+
setClient({
|
|
99
|
+
account: {
|
|
100
|
+
get: async () => ({
|
|
101
|
+
accountId: ACCOUNT_ID,
|
|
102
|
+
accountConfigId: deriveAccountConfigId(SUB),
|
|
103
|
+
}),
|
|
104
|
+
},
|
|
105
|
+
mailbox: {
|
|
106
|
+
get: async () => world.mailbox ?? folder(),
|
|
107
|
+
findByPathPrefix: async () => world.children ?? [],
|
|
108
|
+
},
|
|
109
|
+
filter: {
|
|
110
|
+
listByAccountConfig: async () => world.filters ?? [],
|
|
111
|
+
},
|
|
112
|
+
accountSetting: {
|
|
113
|
+
get: async (_configId: string, name: string) => {
|
|
114
|
+
for (const [role, mailboxId] of Object.entries(
|
|
115
|
+
world.appointments ?? {},
|
|
116
|
+
)) {
|
|
117
|
+
if (name === `FolderRoleAppointment#${ACCOUNT_ID}#${role}`) {
|
|
118
|
+
return { value: { kind: "String", value: mailboxId } };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
mailboxQueue: {
|
|
125
|
+
deleteMailbox: async (mailboxId: string) => {
|
|
126
|
+
deleted.push(mailboxId);
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
} as unknown as RemitClient);
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
deleted,
|
|
133
|
+
run: () =>
|
|
134
|
+
deleteMailbox(
|
|
135
|
+
{
|
|
136
|
+
request: { params: { accountId: ACCOUNT_ID, mailboxId: "mbx-1" } },
|
|
137
|
+
} as unknown as Context,
|
|
138
|
+
{
|
|
139
|
+
requestContext: { authorizer: { claims: { sub: SUB } } },
|
|
140
|
+
} as unknown as APIGatewayProxyEvent,
|
|
141
|
+
),
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const refusal = (match: RegExp) => (error: unknown) =>
|
|
146
|
+
(error as { statusCode?: number }).statusCode === 400 &&
|
|
147
|
+
match.test((error as Error).message);
|
|
148
|
+
|
|
149
|
+
describe("MailboxDetailOperations_deleteMailbox — what it refuses (D4, D16)", () => {
|
|
150
|
+
afterEach(() => {
|
|
151
|
+
_resetForTest();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("refuses the inbox", async () => {
|
|
155
|
+
const { run, deleted } = callDelete({
|
|
156
|
+
mailbox: folder({ fullPath: "INBOX" }),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await assert.rejects(run(), refusal(/inbox can't be deleted/));
|
|
160
|
+
assert.deepEqual(deleted, []);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("refuses a folder that has folders inside it", async () => {
|
|
164
|
+
// The server would keep the parent's name as a placeholder and leave the
|
|
165
|
+
// children untouched — an outcome no local row describes.
|
|
166
|
+
const { run, deleted } = callDelete({
|
|
167
|
+
children: [folder({ mailboxId: "mbx-2", fullPath: "Receipts/2026" })],
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await assert.rejects(run(), refusal(/has folders inside it/));
|
|
171
|
+
assert.deepEqual(deleted, []);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* D16. Refusing is what makes the settled-target rule total: a binding is
|
|
176
|
+
* only created against a settled folder and only removed by the user, so a
|
|
177
|
+
* mailboxId that names nothing never exists and no reader needs a
|
|
178
|
+
* missing-target branch.
|
|
179
|
+
*/
|
|
180
|
+
it("refuses a folder a filter moves mail into, naming the filter", async () => {
|
|
181
|
+
const { run, deleted } = callDelete({
|
|
182
|
+
filters: [{ name: "Invoices → Receipts", actionMailboxId: "mbx-1" }],
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await assert.rejects(run(), refusal(/the filter “Invoices → Receipts”/));
|
|
186
|
+
assert.deepEqual(deleted, []);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("refuses a folder appointed to a role, naming the role", async () => {
|
|
190
|
+
const { run, deleted } = callDelete({ appointments: { Archive: "mbx-1" } });
|
|
191
|
+
|
|
192
|
+
await assert.rejects(run(), refusal(/the Archive role/));
|
|
193
|
+
assert.deepEqual(deleted, []);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("names both when both are bound", async () => {
|
|
197
|
+
const { run } = callDelete({
|
|
198
|
+
filters: [{ name: "Invoices", actionMailboxId: "mbx-1" }],
|
|
199
|
+
appointments: { Archive: "mbx-1" },
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await assert.rejects(
|
|
203
|
+
run(),
|
|
204
|
+
refusal(/the filter “Invoices” and the Archive role/),
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("ignores a filter that moves mail into a different folder", async () => {
|
|
209
|
+
const { run, deleted } = callDelete({
|
|
210
|
+
filters: [{ name: "Elsewhere", actionMailboxId: "mbx-9" }],
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
assert.deepEqual(await run(), { statusCode: 204 });
|
|
214
|
+
assert.deepEqual(deleted, ["mbx-1"]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("ignores a filter with no move action at all", async () => {
|
|
218
|
+
const { run, deleted } = callDelete({
|
|
219
|
+
filters: [{ name: "Just a label", actionMailboxId: "None" }],
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
assert.deepEqual(await run(), { statusCode: 204 });
|
|
223
|
+
assert.deepEqual(deleted, ["mbx-1"]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("records the intent for an ordinary folder", async () => {
|
|
227
|
+
const { run, deleted } = callDelete({});
|
|
228
|
+
|
|
229
|
+
assert.deepEqual(await run(), { statusCode: 204 });
|
|
230
|
+
assert.deepEqual(deleted, ["mbx-1"]);
|
|
231
|
+
});
|
|
232
|
+
});
|
package/src/handlers/mailbox.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { IAccountSettingRepository, MailboxItem } from "@remit/data-ports";
|
|
|
6
6
|
import {
|
|
7
7
|
BadRequestError,
|
|
8
8
|
ForbiddenError,
|
|
9
|
+
MailboxNotSettledError,
|
|
9
10
|
NotFoundError,
|
|
10
11
|
} from "@remit/data-ports/errors";
|
|
11
12
|
import { isReservedFolderName } from "@remit/data-ports/folder-role";
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
type MailboxOverrides,
|
|
32
33
|
} from "./account-overrides.js";
|
|
33
34
|
import { assertAccountOwnership } from "./account-ownership.js";
|
|
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
|
|
@@ -227,6 +229,51 @@ export const assertMailboxInAccount = (
|
|
|
227
229
|
throw new ForbiddenError(`Mailbox ${mailbox.mailboxId} not in account`);
|
|
228
230
|
};
|
|
229
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
|
+
|
|
230
277
|
/**
|
|
231
278
|
* Every pending placement move (issue #1271) for an account, on whichever
|
|
232
279
|
* backend is active (`RemitClient.placementMove` is present on both — see
|
|
@@ -286,24 +333,6 @@ const toMailboxResponse = (
|
|
|
286
333
|
updatedAt: mailbox.updatedAt,
|
|
287
334
|
});
|
|
288
335
|
|
|
289
|
-
/**
|
|
290
|
-
* Drop folders the user has deleted but the imap-worker has not yet reaped.
|
|
291
|
-
*
|
|
292
|
-
* A delete is a soft delete: the row lives on with syncStatus=deleting until the
|
|
293
|
-
* worker confirms the IMAP delete and removes it (mailbox-queue.deleteMailbox).
|
|
294
|
-
* The list is the settings view a client refetches right after confirming, so a
|
|
295
|
-
* still-`deleting` row here would keep a deleted folder on screen until the
|
|
296
|
-
* worker finishes. Hiding it makes the folder leave the list the moment the
|
|
297
|
-
* delete is confirmed; a delete that fails restores the row off `deleting`,
|
|
298
|
-
* which brings the folder back on the next read.
|
|
299
|
-
*/
|
|
300
|
-
export const excludeDeletingMailboxes = (
|
|
301
|
-
mailboxes: readonly MailboxItem[],
|
|
302
|
-
): MailboxItem[] =>
|
|
303
|
-
mailboxes.filter(
|
|
304
|
-
(mailbox) => mailbox.syncStatus !== MailboxSyncStatus.deleting,
|
|
305
|
-
);
|
|
306
|
-
|
|
307
336
|
export const MailboxOperations: Record<
|
|
308
337
|
MailboxOperationIds,
|
|
309
338
|
OperationHandler<MailboxOperationIds>
|
|
@@ -324,8 +353,6 @@ export const MailboxOperations: Record<
|
|
|
324
353
|
continuationToken,
|
|
325
354
|
});
|
|
326
355
|
|
|
327
|
-
const visibleItems = excludeDeletingMailboxes(result.items);
|
|
328
|
-
|
|
329
356
|
// Overrides (mute / display-name / role) live in per-mailbox AccountSetting
|
|
330
357
|
// rows (RFC 032). Load the whole config's set in one query and key it by
|
|
331
358
|
// mailboxId so each mailbox surfaces its overrides without an N+1.
|
|
@@ -342,8 +369,11 @@ export const MailboxOperations: Record<
|
|
|
342
369
|
client,
|
|
343
370
|
accountId,
|
|
344
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.
|
|
345
375
|
const items = applyPendingMoveCountPrediction(
|
|
346
|
-
|
|
376
|
+
result.items,
|
|
347
377
|
pendingMoves,
|
|
348
378
|
pendingUnseenFlagPushes,
|
|
349
379
|
);
|
|
@@ -492,6 +522,31 @@ export const MailboxDetailOperations: Record<
|
|
|
492
522
|
const mailbox = await client.mailbox.get(accountId, mailboxId);
|
|
493
523
|
assertMailboxInAccount(mailbox, accountId, "act");
|
|
494
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
|
+
|
|
495
550
|
await client.mailboxQueue.deleteMailbox(mailboxId, accountId);
|
|
496
551
|
return { statusCode: 204 };
|
|
497
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(
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #1054: the backend must log one structured access line per request
|
|
3
|
+
* (method, path, status, duration, correlation id) at a level that is on in
|
|
4
|
+
* production. The access log lives in `rawHandler` in `src/index.ts`, wrapping
|
|
5
|
+
* the authenticated request path — so a 401 denial from edge-auth also gets an
|
|
6
|
+
* access line (once it flows through `formatResponse` with the correlation id).
|
|
7
|
+
*
|
|
8
|
+
* The 401 response shape (correlation id header + CORS on the denial) is tested
|
|
9
|
+
* directly in `jwt-auth.test.ts` via `runWithRequestContext`; this file tests
|
|
10
|
+
* that the handler emits the access log line on every request by spying on the
|
|
11
|
+
* logger's info method.
|
|
12
|
+
*/
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { describe, it, mock } from "node:test";
|
|
15
|
+
import { logger } from "@remit/logger-lambda";
|
|
16
|
+
import type { APIGatewayProxyEvent, Context } from "aws-lambda";
|
|
17
|
+
import { handler } from "./index.js";
|
|
18
|
+
|
|
19
|
+
const buildEvent = (
|
|
20
|
+
overrides: Partial<APIGatewayProxyEvent> = {},
|
|
21
|
+
): APIGatewayProxyEvent =>
|
|
22
|
+
({
|
|
23
|
+
path: "/api/test",
|
|
24
|
+
httpMethod: "GET",
|
|
25
|
+
pathParameters: {},
|
|
26
|
+
queryStringParameters: {},
|
|
27
|
+
headers: {},
|
|
28
|
+
requestContext: {},
|
|
29
|
+
...overrides,
|
|
30
|
+
}) as unknown as APIGatewayProxyEvent;
|
|
31
|
+
|
|
32
|
+
const buildContext = (correlationId = "ctx-test-1"): Context =>
|
|
33
|
+
({
|
|
34
|
+
awsRequestId: correlationId,
|
|
35
|
+
functionName: "test-function",
|
|
36
|
+
getRemainingTimeInMillis: () => 30000,
|
|
37
|
+
}) as unknown as Context;
|
|
38
|
+
|
|
39
|
+
describe("issue #1054: per-request access log", () => {
|
|
40
|
+
it('emits one "Request completed" line at info level with method, path, status, duration and correlation id', async () => {
|
|
41
|
+
delete process.env.LOCAL_ACCOUNT_CONFIG_ID;
|
|
42
|
+
|
|
43
|
+
// Spy on logger.info — the real logger passes (fields, message) in
|
|
44
|
+
// pino order, so the spy captures the structured fields and message.
|
|
45
|
+
const infoSpy = mock.method(logger, "info");
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await handler(
|
|
49
|
+
buildEvent({ path: "/api/__nonexistent_for_test__" }),
|
|
50
|
+
buildContext("ctx-access-log"),
|
|
51
|
+
).catch(() => {
|
|
52
|
+
// 404s are expected; we only care about the log line.
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// `logger.info` is overloaded ((obj, msg?) and (msg, obj?)), so the
|
|
56
|
+
// mock types `call.arguments` by a single overload. Read the
|
|
57
|
+
// arguments positionally through an unknown[] view instead.
|
|
58
|
+
const accessCalls = infoSpy.mock.calls.filter((call) => {
|
|
59
|
+
// pino calls logger.info(fields, message)
|
|
60
|
+
const args = call.arguments as unknown[];
|
|
61
|
+
return args[1] === "Request completed";
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
assert.ok(accessCalls.length >= 1, "expected an access log line");
|
|
65
|
+
const fields = (accessCalls[0].arguments as unknown[])[0] as Record<
|
|
66
|
+
string,
|
|
67
|
+
unknown
|
|
68
|
+
>;
|
|
69
|
+
|
|
70
|
+
// infoSpy was called on logger.info, so the level is "info" by
|
|
71
|
+
// construction — pino's adapter routes .info() calls to the info
|
|
72
|
+
// level. No separate level field needed.
|
|
73
|
+
|
|
74
|
+
assert.equal(fields.method, "GET");
|
|
75
|
+
assert.equal(fields.path, "/api/__nonexistent_for_test__");
|
|
76
|
+
assert.equal(fields.correlationId, "ctx-access-log");
|
|
77
|
+
assert.equal(typeof fields.statusCode, "number");
|
|
78
|
+
assert.equal(typeof fields.durationMs, "number");
|
|
79
|
+
} finally {
|
|
80
|
+
infoSpy.mock.restore();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -180,9 +180,17 @@ const rawHandler = async (event: APIGatewayProxyEvent, context: Context) => {
|
|
|
180
180
|
logger.debug({ method: event.httpMethod, path }, "Request received");
|
|
181
181
|
|
|
182
182
|
const origin = readOriginHeader(event.headers);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
183
|
+
const startedAt = Date.now();
|
|
184
|
+
// Capture the correlation id here: it is seeded by
|
|
185
|
+
// runWithRequestContext below (from context.awsRequestId), but that
|
|
186
|
+
// AsyncLocalStorage scope does not reliably survive the
|
|
187
|
+
// openapi-backend handleRequest call chain, so we read it from
|
|
188
|
+
// `context.awsRequestId` directly rather than via
|
|
189
|
+
// getRequestCorrelationId at log time.
|
|
190
|
+
const correlationId = context.awsRequestId;
|
|
191
|
+
|
|
192
|
+
const result = await runWithRequestContext(
|
|
193
|
+
{ origin, correlationId },
|
|
186
194
|
async () => {
|
|
187
195
|
if (usesBetterAuthJwt()) {
|
|
188
196
|
const denied = await authenticateSelfHostRequest(event);
|
|
@@ -194,6 +202,25 @@ const rawHandler = async (event: APIGatewayProxyEvent, context: Context) => {
|
|
|
194
202
|
.catch(handleError);
|
|
195
203
|
},
|
|
196
204
|
);
|
|
205
|
+
|
|
206
|
+
// One structured access line per request: method, path, status, and
|
|
207
|
+
// duration. The correlation id is inlined as a top-level field so a bug
|
|
208
|
+
// report quoting the id can grep logs by it directly, and emitted at
|
|
209
|
+
// info so it is on by default in production (the debug "Request
|
|
210
|
+
// received" above already filters out under normal log levels).
|
|
211
|
+
// biome-ignore lint/plugin/no-logger-info: one access line per request is an audit-grade signal (issue #1054)
|
|
212
|
+
logger.info(
|
|
213
|
+
{
|
|
214
|
+
method: event.httpMethod,
|
|
215
|
+
path,
|
|
216
|
+
statusCode: result.statusCode,
|
|
217
|
+
durationMs: Date.now() - startedAt,
|
|
218
|
+
correlationId,
|
|
219
|
+
},
|
|
220
|
+
"Request completed",
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
return result;
|
|
197
224
|
},
|
|
198
225
|
);
|
|
199
226
|
};
|
package/src/jwt-auth.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
_setVerifierForTest,
|
|
6
6
|
authenticateSelfHostRequest,
|
|
7
7
|
} from "./jwt-auth.js";
|
|
8
|
+
import { runWithRequestContext } from "./request-context.js";
|
|
8
9
|
|
|
9
10
|
const buildEvent = (
|
|
10
11
|
overrides: Partial<APIGatewayProxyEvent> = {},
|
|
@@ -41,7 +42,7 @@ test("valid token injects verified sub into authorizer claims", async () => {
|
|
|
41
42
|
assert.equal(event.requestContext.authorizer?.claims?.email, "a@b.com");
|
|
42
43
|
});
|
|
43
44
|
|
|
44
|
-
test("invalid token returns 401
|
|
45
|
+
test("invalid token returns 401 with correlation id and CORS headers", async () => {
|
|
45
46
|
_setVerifierForTest(async () => {
|
|
46
47
|
throw new Error("bad signature");
|
|
47
48
|
});
|
|
@@ -49,9 +50,29 @@ test("invalid token returns 401 and does not inject claims", async () => {
|
|
|
49
50
|
headers: { authorization: "Bearer bad.token" },
|
|
50
51
|
});
|
|
51
52
|
|
|
52
|
-
const result = await
|
|
53
|
+
const result = await runWithRequestContext(
|
|
54
|
+
{ correlationId: "req-invalid-token" },
|
|
55
|
+
async () => authenticateSelfHostRequest(event),
|
|
56
|
+
);
|
|
53
57
|
|
|
54
58
|
assert.equal(result?.statusCode, 401);
|
|
59
|
+
assert.equal(
|
|
60
|
+
result?.headers?.["x-correlation-id"],
|
|
61
|
+
"req-invalid-token",
|
|
62
|
+
"edge-auth 401 must carry the correlation id",
|
|
63
|
+
);
|
|
64
|
+
assert.equal(
|
|
65
|
+
result?.headers?.["Access-Control-Allow-Origin"],
|
|
66
|
+
"*",
|
|
67
|
+
"edge-auth 401 must carry CORS headers",
|
|
68
|
+
);
|
|
69
|
+
assert.equal(
|
|
70
|
+
result?.body,
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
code: "unauthorized",
|
|
73
|
+
message: "Invalid or expired token",
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
55
76
|
assert.equal(event.requestContext.authorizer, undefined);
|
|
56
77
|
});
|
|
57
78
|
|
|
@@ -64,12 +85,25 @@ test("no token with a local bypass configured is allowed", async () => {
|
|
|
64
85
|
assert.equal(result, null);
|
|
65
86
|
});
|
|
66
87
|
|
|
67
|
-
test("no token and no bypass returns 401", async () => {
|
|
88
|
+
test("no token and no bypass returns 401 with correlation id", async () => {
|
|
68
89
|
const event = buildEvent();
|
|
69
90
|
|
|
70
|
-
const result = await
|
|
91
|
+
const result = await runWithRequestContext(
|
|
92
|
+
{ correlationId: "req-no-token" },
|
|
93
|
+
async () => authenticateSelfHostRequest(event),
|
|
94
|
+
);
|
|
71
95
|
|
|
72
96
|
assert.equal(result?.statusCode, 401);
|
|
97
|
+
assert.equal(
|
|
98
|
+
result?.headers?.["x-correlation-id"],
|
|
99
|
+
"req-no-token",
|
|
100
|
+
"edge-auth 401 must carry the correlation id",
|
|
101
|
+
);
|
|
102
|
+
assert.equal(
|
|
103
|
+
result?.headers?.["Access-Control-Allow-Origin"],
|
|
104
|
+
"*",
|
|
105
|
+
"edge-auth 401 must carry CORS headers",
|
|
106
|
+
);
|
|
73
107
|
});
|
|
74
108
|
|
|
75
109
|
test("a tokenless GET to the Microsoft OAuth callback is admitted", async () => {
|
package/src/jwt-auth.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { readCalendarFeedToken } from "@remit/calendar-service";
|
|
|
8
8
|
import { logger } from "@remit/logger-lambda";
|
|
9
9
|
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
10
10
|
import { defaultErrorCode } from "./error.js";
|
|
11
|
+
import { formatResponse } from "./response.js";
|
|
11
12
|
|
|
12
13
|
let verifier: JwtVerifier | null = null;
|
|
13
14
|
|
|
@@ -31,11 +32,8 @@ const readHeader = (
|
|
|
31
32
|
return undefined;
|
|
32
33
|
};
|
|
33
34
|
|
|
34
|
-
const unauthorized = (message: string): APIGatewayProxyResult =>
|
|
35
|
-
|
|
36
|
-
headers: { "Content-Type": "application/json" },
|
|
37
|
-
body: JSON.stringify({ code: defaultErrorCode(401), message }),
|
|
38
|
-
});
|
|
35
|
+
const unauthorized = (message: string): APIGatewayProxyResult =>
|
|
36
|
+
formatResponse({ code: defaultErrorCode(401), message }, 401);
|
|
39
37
|
|
|
40
38
|
const injectClaims = (
|
|
41
39
|
event: APIGatewayProxyEvent,
|