@remit/backend 0.0.96 → 0.0.97
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
CHANGED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #1013: the outbox listing took the head of the configured account list,
|
|
3
|
+
* so on an instance with two accounts the second account's drafts and failed
|
|
4
|
+
* sends were never on screen.
|
|
5
|
+
*
|
|
6
|
+
* The operation takes no account parameter and does not gain one — the fan-out
|
|
7
|
+
* is the handler's job. What is pinned here is that every account under the
|
|
8
|
+
* caller's config is asked for, and that the continuation token the handler
|
|
9
|
+
* hands back resumes the one merged ordering rather than one account's.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { afterEach, describe, it } from "node:test";
|
|
14
|
+
import type {
|
|
15
|
+
AccountItem,
|
|
16
|
+
IAccountRepository,
|
|
17
|
+
IOutboxMessageRepository,
|
|
18
|
+
OutboxMessageItem,
|
|
19
|
+
ResultList,
|
|
20
|
+
} from "@remit/data-ports";
|
|
21
|
+
import { APPENDED_UID_NONE } from "@remit/data-ports";
|
|
22
|
+
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
23
|
+
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
24
|
+
import type { Context } from "openapi-backend";
|
|
25
|
+
import { deriveAccountConfigId } from "../auth.js";
|
|
26
|
+
import {
|
|
27
|
+
_resetForTest,
|
|
28
|
+
type RemitClient,
|
|
29
|
+
setClient,
|
|
30
|
+
} from "../service/data-client.js";
|
|
31
|
+
import { OutboxOperations } from "./outbox.js";
|
|
32
|
+
|
|
33
|
+
const SUB = "cognito-sub-1013";
|
|
34
|
+
const ACCOUNT_CONFIG_ID = deriveAccountConfigId(SUB);
|
|
35
|
+
const FIRST_ACCOUNT = "acc-first";
|
|
36
|
+
const SECOND_ACCOUNT = "acc-second";
|
|
37
|
+
|
|
38
|
+
const outboxRow = (
|
|
39
|
+
outboxMessageId: string,
|
|
40
|
+
accountId: string,
|
|
41
|
+
createdAt: number,
|
|
42
|
+
): OutboxMessageItem => ({
|
|
43
|
+
outboxMessageId,
|
|
44
|
+
accountId,
|
|
45
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
46
|
+
fromAddress: `${accountId}@example.com`,
|
|
47
|
+
toAddresses: ["recipient@example.com"],
|
|
48
|
+
ccAddresses: [],
|
|
49
|
+
bccAddresses: [],
|
|
50
|
+
references: [],
|
|
51
|
+
messageIdValue: `<${outboxMessageId}@example.com>`,
|
|
52
|
+
status: OutboxMessageStatus.draft,
|
|
53
|
+
appendedUid: APPENDED_UID_NONE,
|
|
54
|
+
createdAt,
|
|
55
|
+
updatedAt: createdAt,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Four rows, the two accounts alternating in time, so a page of two can only
|
|
60
|
+
* hold rows from both and a listing that follows one account is visibly short.
|
|
61
|
+
*/
|
|
62
|
+
const rows: OutboxMessageItem[] = [
|
|
63
|
+
outboxRow("out-1", FIRST_ACCOUNT, 4000),
|
|
64
|
+
outboxRow("out-2", SECOND_ACCOUNT, 3000),
|
|
65
|
+
outboxRow("out-3", FIRST_ACCOUNT, 2000),
|
|
66
|
+
outboxRow("out-4", SECOND_ACCOUNT, 1000),
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const askedFor: string[][] = [];
|
|
70
|
+
|
|
71
|
+
/** The keyset the drizzle repository implements, over the fixture above. */
|
|
72
|
+
const outboxRepository = {
|
|
73
|
+
listByAccounts: async (
|
|
74
|
+
accountIds: string[],
|
|
75
|
+
options?: { limit?: number; continuationToken?: string },
|
|
76
|
+
): Promise<ResultList<OutboxMessageItem>> => {
|
|
77
|
+
askedFor.push([...accountIds]);
|
|
78
|
+
const limit = options?.limit ?? 2;
|
|
79
|
+
const after = options?.continuationToken
|
|
80
|
+
? (JSON.parse(
|
|
81
|
+
Buffer.from(options.continuationToken, "base64url").toString("utf8"),
|
|
82
|
+
) as { createdAt: number })
|
|
83
|
+
: undefined;
|
|
84
|
+
const matching = rows
|
|
85
|
+
.filter((row) => accountIds.includes(row.accountId))
|
|
86
|
+
.filter((row) => after === undefined || row.createdAt < after.createdAt)
|
|
87
|
+
.sort((left, right) => right.createdAt - left.createdAt);
|
|
88
|
+
const items = matching.slice(0, limit);
|
|
89
|
+
const last = items[items.length - 1];
|
|
90
|
+
const hasMore = matching.length > limit;
|
|
91
|
+
return {
|
|
92
|
+
items,
|
|
93
|
+
continuationToken:
|
|
94
|
+
hasMore && last
|
|
95
|
+
? Buffer.from(JSON.stringify({ createdAt: last.createdAt })).toString(
|
|
96
|
+
"base64url",
|
|
97
|
+
)
|
|
98
|
+
: undefined,
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
} as unknown as IOutboxMessageRepository;
|
|
102
|
+
|
|
103
|
+
const accountRepository = {
|
|
104
|
+
listAllByAccountConfig: async (): Promise<AccountItem[]> =>
|
|
105
|
+
[FIRST_ACCOUNT, SECOND_ACCOUNT].map(
|
|
106
|
+
(accountId) =>
|
|
107
|
+
({
|
|
108
|
+
accountId,
|
|
109
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
110
|
+
email: `${accountId}@example.com`,
|
|
111
|
+
}) as unknown as AccountItem,
|
|
112
|
+
),
|
|
113
|
+
} as unknown as IAccountRepository;
|
|
114
|
+
|
|
115
|
+
const installClient = (): void => {
|
|
116
|
+
askedFor.length = 0;
|
|
117
|
+
setClient({
|
|
118
|
+
account: accountRepository,
|
|
119
|
+
outboxMessage: outboxRepository,
|
|
120
|
+
} as unknown as RemitClient);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const authorizedEvent = (): APIGatewayProxyEvent =>
|
|
124
|
+
({
|
|
125
|
+
body: null,
|
|
126
|
+
requestContext: { authorizer: { claims: { sub: SUB } } },
|
|
127
|
+
}) as unknown as APIGatewayProxyEvent;
|
|
128
|
+
|
|
129
|
+
const queryContext = (query: Record<string, string> = {}): Context =>
|
|
130
|
+
({ request: { query } }) as unknown as Context;
|
|
131
|
+
|
|
132
|
+
type ListResult = {
|
|
133
|
+
items: { outboxMessageId: string; accountId: string }[];
|
|
134
|
+
continuationToken?: string;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const listOutbox = async (query?: Record<string, string>) =>
|
|
138
|
+
(await (
|
|
139
|
+
OutboxOperations.OutboxOperations_listOutboxMessages as unknown as (
|
|
140
|
+
context: Context,
|
|
141
|
+
event: APIGatewayProxyEvent,
|
|
142
|
+
) => Promise<ListResult>
|
|
143
|
+
)(queryContext(query), authorizedEvent())) as ListResult;
|
|
144
|
+
|
|
145
|
+
afterEach(() => {
|
|
146
|
+
_resetForTest();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("the outbox of an instance with two accounts (#1013)", () => {
|
|
150
|
+
it("lists rows from every account, not just the first", async () => {
|
|
151
|
+
installClient();
|
|
152
|
+
|
|
153
|
+
const first = await listOutbox();
|
|
154
|
+
|
|
155
|
+
assert.deepEqual(askedFor, [[FIRST_ACCOUNT, SECOND_ACCOUNT]]);
|
|
156
|
+
assert.deepEqual(
|
|
157
|
+
[...new Set(first.items.map((item) => item.accountId))].sort(),
|
|
158
|
+
[FIRST_ACCOUNT, SECOND_ACCOUNT],
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("resumes the merged ordering on the second page", async () => {
|
|
163
|
+
installClient();
|
|
164
|
+
|
|
165
|
+
const first = await listOutbox();
|
|
166
|
+
assert.ok(first.continuationToken, "a second page is offered");
|
|
167
|
+
|
|
168
|
+
const second = await listOutbox({
|
|
169
|
+
continuationToken: first.continuationToken,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const seen = [...first.items, ...second.items].map(
|
|
173
|
+
(item) => item.outboxMessageId,
|
|
174
|
+
);
|
|
175
|
+
assert.deepEqual(seen, ["out-1", "out-2", "out-3", "out-4"]);
|
|
176
|
+
assert.equal(new Set(seen).size, 4, "no row on both pages");
|
|
177
|
+
assert.equal(second.continuationToken, undefined, "paging terminates");
|
|
178
|
+
});
|
|
179
|
+
});
|
package/src/handlers/outbox.ts
CHANGED
|
@@ -128,15 +128,16 @@ export const OutboxOperations: Record<
|
|
|
128
128
|
|
|
129
129
|
const client = await getClient();
|
|
130
130
|
|
|
131
|
-
const accounts =
|
|
132
|
-
|
|
131
|
+
const accounts =
|
|
132
|
+
await client.account.listAllByAccountConfig(accountConfigId);
|
|
133
|
+
if (accounts.length === 0) {
|
|
133
134
|
return { items: [], continuationToken: null };
|
|
134
135
|
}
|
|
135
136
|
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
continuationToken,
|
|
139
|
-
|
|
137
|
+
const result = await client.outboxMessage.listByAccounts(
|
|
138
|
+
accounts.map((account) => account.accountId),
|
|
139
|
+
{ continuationToken },
|
|
140
|
+
);
|
|
140
141
|
|
|
141
142
|
return {
|
|
142
143
|
items: result.items.map((item) => toOutboxMessageResponse(item)),
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
IAccountSettingRepository,
|
|
4
4
|
MailboxItem,
|
|
5
5
|
} from "@remit/data-ports";
|
|
6
|
+
import { isVirtualCopyMailbox } from "@remit/data-ports/virtual-copy";
|
|
6
7
|
import { MailboxSpecialUse } from "@remit/domain-enums";
|
|
7
8
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
8
9
|
import type { Context } from "openapi-backend";
|
|
@@ -81,32 +82,6 @@ const SEARCH_EXCLUDED_SPECIAL_USE: readonly string[] = [
|
|
|
81
82
|
MailboxSpecialUse.Trash,
|
|
82
83
|
];
|
|
83
84
|
|
|
84
|
-
/**
|
|
85
|
-
* Special-use folders that hold a second copy of mail already reachable through
|
|
86
|
-
* the folder it actually lives in — Gmail's All Mail, Starred and Important.
|
|
87
|
-
* Used to pick which duplicate to drop, never to decide what is searched.
|
|
88
|
-
*/
|
|
89
|
-
const VIRTUAL_COPY_SPECIAL_USE: readonly string[] = [
|
|
90
|
-
MailboxSpecialUse.All,
|
|
91
|
-
MailboxSpecialUse.Flagged,
|
|
92
|
-
MailboxSpecialUse.Important,
|
|
93
|
-
];
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Well-known Gmail virtual paths, for servers that do not advertise the
|
|
97
|
-
* special-use attribute. Whole path, never a prefix — a user's own
|
|
98
|
-
* `Starred ideas` folder is real mail.
|
|
99
|
-
*/
|
|
100
|
-
const VIRTUAL_COPY_FULL_PATHS: readonly string[] = [
|
|
101
|
-
"[gmail]/all mail",
|
|
102
|
-
"[gmail]/starred",
|
|
103
|
-
"[gmail]/important",
|
|
104
|
-
];
|
|
105
|
-
|
|
106
|
-
const isVirtualCopy = (mailbox: MailboxItem): boolean =>
|
|
107
|
-
mailbox.specialUse?.some((use) => VIRTUAL_COPY_SPECIAL_USE.includes(use)) ===
|
|
108
|
-
true || VIRTUAL_COPY_FULL_PATHS.includes(mailbox.fullPath.toLowerCase());
|
|
109
|
-
|
|
110
85
|
const isExcludedFromSearch = (mailbox: MailboxItem): boolean =>
|
|
111
86
|
mailbox.specialUse?.some((use) =>
|
|
112
87
|
SEARCH_EXCLUDED_SPECIAL_USE.includes(use),
|
|
@@ -202,7 +177,7 @@ export const buildInboxMailboxMap = async (
|
|
|
202
177
|
if (!isExcludedFromSearch(mailbox)) {
|
|
203
178
|
searchMailboxIds.add(mailbox.mailboxId);
|
|
204
179
|
}
|
|
205
|
-
if (
|
|
180
|
+
if (isVirtualCopyMailbox(mailbox)) {
|
|
206
181
|
virtualCopyMailboxIds.add(mailbox.mailboxId);
|
|
207
182
|
}
|
|
208
183
|
if (mailbox.fullPath.toUpperCase() === "INBOX") {
|