@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.96",
3
+ "version": "0.0.97",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -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
+ });
@@ -128,15 +128,16 @@ export const OutboxOperations: Record<
128
128
 
129
129
  const client = await getClient();
130
130
 
131
- const accounts = await client.account.list(accountConfigId);
132
- if (accounts.items.length === 0) {
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 accountId = accounts.items[0].accountId;
137
- const result = await client.outboxMessage.listByAccount(accountId, {
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 (isVirtualCopy(mailbox)) {
180
+ if (isVirtualCopyMailbox(mailbox)) {
206
181
  virtualCopyMailboxIds.add(mailbox.mailboxId);
207
182
  }
208
183
  if (mailbox.fullPath.toUpperCase() === "INBOX") {