@remit/backend 0.0.100 → 0.0.102

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.100",
3
+ "version": "0.0.102",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -105,14 +105,6 @@ const testOAuthConnection = async (
105
105
  account: IAccountRepository,
106
106
  secrets: SecretsService,
107
107
  ): Promise<TestConnectionResponse> => {
108
- if (!existingAccount.oauthRefreshTokenHash) {
109
- return {
110
- imapSuccess: false,
111
- imapError: "oauth_not_configured",
112
- smtpSuccess: undefined,
113
- };
114
- }
115
-
116
108
  const tokenService = createMailOAuthService(
117
109
  microsoftProviderConfig({
118
110
  clientId: process.env.MSOAUTH_CLIENT_ID ?? "",
@@ -123,9 +115,9 @@ const testOAuthConnection = async (
123
115
  }),
124
116
  );
125
117
 
126
- let credentials: Awaited<ReturnType<typeof resolveConnectionCredentials>>;
118
+ let resolution: Awaited<ReturnType<typeof resolveConnectionCredentials>>;
127
119
  try {
128
- credentials = await resolveConnectionCredentials(existingAccount, {
120
+ resolution = await resolveConnectionCredentials(existingAccount, {
129
121
  secrets,
130
122
  tokenService,
131
123
  persistRotatedToken: async (id, encryptedHash, updatedAt) => {
@@ -149,6 +141,15 @@ const testOAuthConnection = async (
149
141
  throw err;
150
142
  }
151
143
 
144
+ if (resolution.status === "missing") {
145
+ return {
146
+ imapSuccess: false,
147
+ imapError: "oauth_not_configured",
148
+ smtpSuccess: undefined,
149
+ };
150
+ }
151
+ const credentials = resolution.credentials;
152
+
152
153
  const result: TestConnectionResponse = {
153
154
  imapSuccess: false,
154
155
  smtpSuccess: undefined,
@@ -1,8 +1,18 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import type { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
4
+ import { AccountAuthType } from "@remit/domain-enums";
4
5
  import { triggerConfigLoadSyncs } from "./config.js";
5
6
 
7
+ /**
8
+ * The credential a password account stores. Its content is never read here —
9
+ * only its presence decides whether a sync is worth enqueuing.
10
+ */
11
+ const withPassword = (accountId: string) => ({
12
+ accountId,
13
+ passwordHash: '{"ciphertext":"x"}',
14
+ });
15
+
6
16
  const QUEUE_URL = "http://localhost:9324/000000000000/remit-dev-mailboxes";
7
17
 
8
18
  interface LogCall {
@@ -44,17 +54,60 @@ describe("triggerConfigLoadSyncs", () => {
44
54
  const sent: SendMessageCommand[] = [];
45
55
  const { logger, info, error } = createLoggerSpy();
46
56
 
47
- await triggerConfigLoadSyncs("config-1", ["acc-a", "acc-b"], {
48
- sqsClient: okSqsClient(sent),
49
- queueUrl: QUEUE_URL,
50
- logger,
51
- });
57
+ await triggerConfigLoadSyncs(
58
+ "config-1",
59
+ [withPassword("acc-a"), withPassword("acc-b")],
60
+ {
61
+ sqsClient: okSqsClient(sent),
62
+ queueUrl: QUEUE_URL,
63
+ logger,
64
+ },
65
+ );
52
66
 
53
67
  assert.equal(sent.length, 2);
54
68
  assert.equal(info.length, 2);
55
69
  assert.equal(error.length, 0);
56
70
  });
57
71
 
72
+ it("skips an account that stores no credential (issue #1120)", async () => {
73
+ const sent: SendMessageCommand[] = [];
74
+ const { logger, error } = createLoggerSpy();
75
+
76
+ await triggerConfigLoadSyncs(
77
+ "config-1",
78
+ [
79
+ { accountId: "imported-no-password" },
80
+ withPassword("acc-with-password"),
81
+ ],
82
+ { sqsClient: okSqsClient(sent), queueUrl: QUEUE_URL, logger },
83
+ );
84
+
85
+ assert.equal(sent.length, 1);
86
+ assert.equal(error.length, 0);
87
+ const body = JSON.parse(String(sent[0]?.input.MessageBody)) as {
88
+ accountId: string;
89
+ };
90
+ assert.equal(body.accountId, "acc-with-password");
91
+ });
92
+
93
+ it("skips an OAuth account that stores no refresh token", async () => {
94
+ const sent: SendMessageCommand[] = [];
95
+ const { logger } = createLoggerSpy();
96
+
97
+ await triggerConfigLoadSyncs(
98
+ "config-1",
99
+ [
100
+ {
101
+ accountId: "oauth-unconnected",
102
+ authType: AccountAuthType.OauthMicrosoft,
103
+ },
104
+ ],
105
+ { sqsClient: okSqsClient(sent), queueUrl: QUEUE_URL, logger },
106
+ );
107
+
108
+ assert.equal(sent.length, 0);
109
+ });
110
+
58
111
  it("does NOT reject when the SQS enqueue fails (read stays resilient)", async () => {
59
112
  const econnrefused = Object.assign(new Error(""), {
60
113
  name: "AggregateError",
@@ -63,7 +116,7 @@ describe("triggerConfigLoadSyncs", () => {
63
116
  const { logger } = createLoggerSpy();
64
117
 
65
118
  await assert.doesNotReject(
66
- triggerConfigLoadSyncs("config-1", ["acc-a"], {
119
+ triggerConfigLoadSyncs("config-1", [withPassword("acc-a")], {
67
120
  sqsClient: failingSqsClient(econnrefused),
68
121
  queueUrl: QUEUE_URL,
69
122
  logger,
@@ -78,7 +131,7 @@ describe("triggerConfigLoadSyncs", () => {
78
131
  });
79
132
  const { logger, error } = createLoggerSpy();
80
133
 
81
- await triggerConfigLoadSyncs("config-1", ["acc-a"], {
134
+ await triggerConfigLoadSyncs("config-1", [withPassword("acc-a")], {
82
135
  sqsClient: failingSqsClient(econnrefused),
83
136
  queueUrl: QUEUE_URL,
84
137
  logger,
@@ -109,11 +162,15 @@ describe("triggerConfigLoadSyncs", () => {
109
162
  } as unknown as SQSClient;
110
163
  const { logger, info, error } = createLoggerSpy();
111
164
 
112
- await triggerConfigLoadSyncs("config-1", ["acc-a", "acc-b"], {
113
- sqsClient,
114
- queueUrl: QUEUE_URL,
115
- logger,
116
- });
165
+ await triggerConfigLoadSyncs(
166
+ "config-1",
167
+ [withPassword("acc-a"), withPassword("acc-b")],
168
+ {
169
+ sqsClient,
170
+ queueUrl: QUEUE_URL,
171
+ logger,
172
+ },
173
+ );
117
174
 
118
175
  assert.equal(error.length, 1);
119
176
  assert.equal(info.length, 1);
@@ -127,7 +184,7 @@ describe("triggerConfigLoadSyncs", () => {
127
184
  });
128
185
  const { logger, error } = createLoggerSpy();
129
186
 
130
- await triggerConfigLoadSyncs("config-1", ["acc-a"], {
187
+ await triggerConfigLoadSyncs("config-1", [withPassword("acc-a")], {
131
188
  sqsClient: failingSqsClient(sdkError),
132
189
  queueUrl: QUEUE_URL,
133
190
  logger,
@@ -161,11 +218,15 @@ describe("triggerConfigLoadSyncs", () => {
161
218
  let readResolved = false;
162
219
  try {
163
220
  // Fire-and-forget exactly as the getConfig handler does.
164
- void triggerConfigLoadSyncs("config-1", ["acc-a", "acc-b"], {
165
- sqsClient: failingSqsClient(econnrefused),
166
- queueUrl: QUEUE_URL,
167
- logger,
168
- });
221
+ void triggerConfigLoadSyncs(
222
+ "config-1",
223
+ [withPassword("acc-a"), withPassword("acc-b")],
224
+ {
225
+ sqsClient: failingSqsClient(econnrefused),
226
+ queueUrl: QUEUE_URL,
227
+ logger,
228
+ },
229
+ );
169
230
 
170
231
  // A concurrent "read" sharing the event loop, like /mailboxes or /outbox.
171
232
  await new Promise((resolve) => setImmediate(resolve)).then(() => {
@@ -14,6 +14,10 @@ import type { AccountConfigItem, MailboxItem } from "@remit/data-ports";
14
14
  import { ConfigNotEmptyError, NotFoundError } from "@remit/data-ports/errors";
15
15
  import type { CanonicalMailboxRoleValue } from "@remit/data-ports/folder-role";
16
16
  import { logger } from "@remit/logger-lambda";
17
+ import {
18
+ hasStoredCredential,
19
+ type StoredCredentialFields,
20
+ } from "@remit/mailbox-service/account-credentials";
17
21
  import {
18
22
  EMBEDDING_PROVIDER_OFF,
19
23
  readEmbeddingProviderFromEnv,
@@ -77,14 +81,20 @@ const defaultConfigSyncTriggerDeps = (): ConfigSyncTriggerDeps => ({
77
81
  * Each failure is logged as a distinct structured error carrying the SDK error
78
82
  * name/code on dedicated fields plus a stable `alert: "sync_trigger_failed"`
79
83
  * discriminator a CloudWatch metric filter / alarm can key off.
84
+ *
85
+ * An account storing no credential is not enqueued at all (issue #1120): the
86
+ * import wizard lands accounts without a password by design, and the client
87
+ * polls this read while the user is still typing one. The guard is the stored
88
+ * credential rather than `connectionState`, which stays `credentials_missing`
89
+ * until a connect succeeds — see `hasStoredCredential`.
80
90
  */
81
91
  export const triggerConfigLoadSyncs = async (
82
92
  accountConfigId: string,
83
- accountIds: ReadonlyArray<string>,
93
+ accounts: ReadonlyArray<{ accountId: string } & StoredCredentialFields>,
84
94
  deps: ConfigSyncTriggerDeps = defaultConfigSyncTriggerDeps(),
85
95
  ): Promise<void> => {
86
96
  await Promise.all(
87
- accountIds.map((accountId) =>
97
+ accounts.filter(hasStoredCredential).map(({ accountId }) =>
88
98
  fireAndForget(
89
99
  async () => {
90
100
  const { eventId } = await triggerAccountSync({
@@ -224,10 +234,7 @@ export const ConfigOperations: Record<
224
234
  // triggerConfigLoadSyncs swallows nothing — it logs each failure loudly
225
235
  // with an alertable structured field — but it also never rejects, so the
226
236
  // `void` here cannot leak an unhandled rejection into a later request.
227
- void triggerConfigLoadSyncs(
228
- accountConfigId,
229
- activeAccounts.map((acc) => acc.accountId),
230
- );
237
+ void triggerConfigLoadSyncs(accountConfigId, activeAccounts);
231
238
 
232
239
  // An import that named folders IMAP had not produced yet rides the config
233
240
  // read rather than a route of its own, so nothing has to poll for it.
@@ -126,7 +126,11 @@ describe("executeThreadSearch", () => {
126
126
  updatedAt: 0,
127
127
  });
128
128
 
129
- type RecordedCall = { search: { category?: Category[] } };
129
+ type RecordedCall = {
130
+ search: { category?: Category[] };
131
+ continuationToken?: string;
132
+ limit?: number;
133
+ };
130
134
 
131
135
  const fakeClient = (rows: ThreadMessageItem[]) => {
132
136
  const windowCalls: RecordedCall[] = [];
@@ -134,7 +138,8 @@ describe("executeThreadSearch", () => {
134
138
 
135
139
  // The fake applies the category predicate itself, the way a port does, so
136
140
  // a request that never reaches `search` cannot answer correctly by
137
- // accident.
141
+ // accident. The window also honours `limit`, so a handler deriving a count
142
+ // from the rows it got back cannot pass for one that asked the port.
138
143
  const matching = (categories?: Category[]) =>
139
144
  categories?.length
140
145
  ? rows.filter((row) => categories.includes(row.category))
@@ -142,10 +147,14 @@ describe("executeThreadSearch", () => {
142
147
 
143
148
  const client: ThreadSearchClient = {
144
149
  threadMessage: {
145
- async searchByMailboxWindow(_account, _mailbox, search) {
146
- windowCalls.push({ search });
150
+ async searchByMailboxWindow(_account, _mailbox, search, options) {
151
+ windowCalls.push({
152
+ search,
153
+ continuationToken: options?.continuationToken,
154
+ limit: options?.limit,
155
+ });
147
156
  return {
148
- items: matching(search.category),
157
+ items: matching(search.category).slice(0, options?.limit),
149
158
  continuationToken: undefined,
150
159
  };
151
160
  },
@@ -276,6 +285,110 @@ describe("executeThreadSearch", () => {
276
285
  assert.equal(response.count, matches);
277
286
  });
278
287
 
288
+ // #305: `senderTrust` and `dkimMismatch` are resolved by enriching rows, so
289
+ // the only number the handler could put in `count` is this page's post-filter
290
+ // length — a page length wearing the name of a total. Absent instead.
291
+ it("omits count under senderTrust rather than counting the page", async () => {
292
+ const { client, countCalls } = fakeClient(ACCOUNT_ROWS);
293
+
294
+ const response = await executeThreadSearch(client, ACCOUNT, MAILBOX, {
295
+ senderTrust: [SenderTrust.Unknown],
296
+ count: true,
297
+ });
298
+
299
+ assert.equal(response.count, undefined);
300
+ assert.equal(countCalls.length, 0, "nothing to count off the row");
301
+ assert.equal(response.items?.length, ACCOUNT_ROWS.length);
302
+ });
303
+
304
+ it("omits count under dkimMismatch rather than counting the page", async () => {
305
+ const { client } = fakeClient(ACCOUNT_ROWS);
306
+
307
+ const response = await executeThreadSearch(client, ACCOUNT, MAILBOX, {
308
+ dkimMismatch: true,
309
+ count: true,
310
+ });
311
+
312
+ assert.equal(response.count, undefined);
313
+ });
314
+
315
+ // The count it asked for cannot be answered and the rows it declined are not
316
+ // wanted, so there is nothing left to read.
317
+ it("reads nothing for a count-only request under an off-row criterion", async () => {
318
+ const { client, windowCalls, countCalls } = fakeClient(ACCOUNT_ROWS);
319
+
320
+ const response = await executeThreadSearch(client, ACCOUNT, MAILBOX, {
321
+ dkimMismatch: true,
322
+ count: true,
323
+ results: false,
324
+ });
325
+
326
+ assert.deepEqual(response, {});
327
+ assert.equal(windowCalls.length, 0);
328
+ assert.equal(countCalls.length, 0);
329
+ });
330
+
331
+ it("answers the same count whether or not rows were asked for", async () => {
332
+ const criteria = {
333
+ category: [MessageCategory.personal, MessageCategory.marketing],
334
+ count: true,
335
+ };
336
+ const withRows = await executeThreadSearch(
337
+ fakeClient(ACCOUNT_ROWS).client,
338
+ ACCOUNT,
339
+ MAILBOX,
340
+ criteria,
341
+ );
342
+ const countOnly = await executeThreadSearch(
343
+ fakeClient(ACCOUNT_ROWS).client,
344
+ ACCOUNT,
345
+ MAILBOX,
346
+ { ...criteria, results: false },
347
+ );
348
+
349
+ assert.equal(withRows.count, 2);
350
+ assert.equal(countOnly.count, withRows.count);
351
+ });
352
+
353
+ // The page size bounds the rows; it has no say in how many messages match.
354
+ it("holds the count still while the page size moves", async () => {
355
+ const responses = [];
356
+ for (const limit of [1, 500]) {
357
+ const { client } = fakeClient(ACCOUNT_ROWS);
358
+ responses.push(
359
+ await executeThreadSearch(client, ACCOUNT, MAILBOX, {
360
+ count: true,
361
+ limit,
362
+ }),
363
+ );
364
+ }
365
+
366
+ assert.deepEqual(
367
+ responses.map((response) => response.items?.length),
368
+ [1, ACCOUNT_ROWS.length],
369
+ "the page did move",
370
+ );
371
+ assert.deepEqual(
372
+ responses.map((response) => response.count),
373
+ [ACCOUNT_ROWS.length, ACCOUNT_ROWS.length],
374
+ );
375
+ });
376
+
377
+ // The cursor never reaches the count query, so the number does not shrink as
378
+ // the user pages: page three claims the same total page one did.
379
+ it("holds the count still while the caller pages", async () => {
380
+ const { client, windowCalls, countCalls } = fakeClient(ACCOUNT_ROWS);
381
+
382
+ const response = await executeThreadSearch(client, ACCOUNT, MAILBOX, {
383
+ count: true,
384
+ continuationToken: "page-three",
385
+ });
386
+
387
+ assert.equal(windowCalls[0].continuationToken, "page-three");
388
+ assert.equal(countCalls.length, 1);
389
+ assert.equal(response.count, ACCOUNT_ROWS.length);
390
+ });
391
+
279
392
  it("projects category, so the DynamoDB port reads it with the row", () => {
280
393
  assert.ok(buildSearchThreadsOptions({}).attributes.includes("category"));
281
394
  assert.ok(buildListThreadsOptions({}).attributes.includes("category"));
@@ -161,10 +161,16 @@ export type ThreadSearchParams = {
161
161
  * `results` toggle omits `items` (count-only) and `count` adds the match count.
162
162
  *
163
163
  * `count` names the whole match, never the page: a page size bounds the rows a
164
- * response carries and has no bearing on how many messages match (#509). The
165
- * one exception is an off-row criterion, which no Select:COUNT can see — those
166
- * force a window read+enrich even in count-only mode, and the count that comes
167
- * back is over the enriched window, which the API documents as such.
164
+ * response carries and has no bearing on how many messages match (#509).
165
+ *
166
+ * An off-row criterion is the one shape that has no such number. `senderTrust`
167
+ * and `dkimMismatch` are resolved by enriching a window, so counting them means
168
+ * enriching every candidate row in the mailbox. `count` is therefore absent
169
+ * (#305) — a page's post-filter length labelled `count` is a page length
170
+ * presented as a total, which is the defect the field exists to avoid. Absence
171
+ * is the same answer the response already gives for a count nobody asked for,
172
+ * so no caller needs a second shape to read it. A count-only request under an
173
+ * off-row criterion therefore reads nothing at all.
168
174
  */
169
175
  export const executeThreadSearch = async (
170
176
  client: ThreadSearchClient,
@@ -194,6 +200,7 @@ export const executeThreadSearch = async (
194
200
  });
195
201
 
196
202
  if (hasOffRowCriteria(offRow)) {
203
+ if (!wantResults) return {};
197
204
  const window = await client.threadMessage.searchByMailboxWindow(
198
205
  accountConfigId,
199
206
  mailboxId,
@@ -205,12 +212,9 @@ export const executeThreadSearch = async (
205
212
  client,
206
213
  accountConfigId,
207
214
  );
208
- const filtered = filterByOffRowCriteria(enriched, offRow);
209
215
  return {
210
- ...(wantResults
211
- ? { items: filtered, continuationToken: window.continuationToken }
212
- : {}),
213
- ...(wantCount ? { count: filtered.length } : {}),
216
+ items: filterByOffRowCriteria(enriched, offRow),
217
+ continuationToken: window.continuationToken,
214
218
  };
215
219
  }
216
220
 
@@ -20,7 +20,6 @@ const parseBody = (cmd: SendMessageCommand): Record<string, unknown> => {
20
20
  describe("buildSyncMailboxesCommand", () => {
21
21
  it("sets MessageGroupId to accountId for FIFO queues", () => {
22
22
  const cmd = buildSyncMailboxesCommand({
23
- sqsClient: {} as SQSClient,
24
23
  queueUrl: FIFO_QUEUE_URL,
25
24
  accountId: "account-abc",
26
25
  });
@@ -30,7 +29,6 @@ describe("buildSyncMailboxesCommand", () => {
30
29
 
31
30
  it("defaults MessageDeduplicationId to the event's own id for FIFO queues", () => {
32
31
  const cmd = buildSyncMailboxesCommand({
33
- sqsClient: {} as SQSClient,
34
32
  queueUrl: FIFO_QUEUE_URL,
35
33
  accountId: "account-abc",
36
34
  });
@@ -44,12 +42,10 @@ describe("buildSyncMailboxesCommand", () => {
44
42
  // must each reach the queue.
45
43
  it("gives two triggers for one account distinct dedup ids", () => {
46
44
  const first = buildSyncMailboxesCommand({
47
- sqsClient: {} as SQSClient,
48
45
  queueUrl: FIFO_QUEUE_URL,
49
46
  accountId: "account-abc",
50
47
  });
51
48
  const second = buildSyncMailboxesCommand({
52
- sqsClient: {} as SQSClient,
53
49
  queueUrl: FIFO_QUEUE_URL,
54
50
  accountId: "account-abc",
55
51
  });
@@ -65,7 +61,6 @@ describe("buildSyncMailboxesCommand", () => {
65
61
  // skips the ones that were just enumerated.
66
62
  it("marks an explicitly-requested trigger on the event", () => {
67
63
  const cmd = buildSyncMailboxesCommand({
68
- sqsClient: {} as SQSClient,
69
64
  queueUrl: FIFO_QUEUE_URL,
70
65
  accountId: "account-abc",
71
66
  explicitRequest: true,
@@ -76,7 +71,6 @@ describe("buildSyncMailboxesCommand", () => {
76
71
 
77
72
  it("leaves a side-effect trigger unmarked", () => {
78
73
  const cmd = buildSyncMailboxesCommand({
79
- sqsClient: {} as SQSClient,
80
74
  queueUrl: FIFO_QUEUE_URL,
81
75
  accountId: "account-abc",
82
76
  });
@@ -86,7 +80,6 @@ describe("buildSyncMailboxesCommand", () => {
86
80
 
87
81
  it("does not set FIFO params for standard queues", () => {
88
82
  const cmd = buildSyncMailboxesCommand({
89
- sqsClient: {} as SQSClient,
90
83
  queueUrl: STANDARD_QUEUE_URL,
91
84
  accountId: "account-abc",
92
85
  });
@@ -97,7 +90,6 @@ describe("buildSyncMailboxesCommand", () => {
97
90
 
98
91
  it("builds a SYNC_MAILBOXES event body with accountId and eventId", () => {
99
92
  const cmd = buildSyncMailboxesCommand({
100
- sqsClient: {} as SQSClient,
101
93
  queueUrl: FIFO_QUEUE_URL,
102
94
  accountId: "account-abc",
103
95
  });
@@ -111,7 +103,6 @@ describe("buildSyncMailboxesCommand", () => {
111
103
 
112
104
  it("targets the configured queue url", () => {
113
105
  const cmd = buildSyncMailboxesCommand({
114
- sqsClient: {} as SQSClient,
115
106
  queueUrl: FIFO_QUEUE_URL,
116
107
  accountId: "account-abc",
117
108
  });
@@ -121,7 +112,6 @@ describe("buildSyncMailboxesCommand", () => {
121
112
 
122
113
  it("uses the caller-supplied dedupId instead of the manual default", () => {
123
114
  const cmd = buildSyncMailboxesCommand({
124
- sqsClient: {} as SQSClient,
125
115
  queueUrl: FIFO_QUEUE_URL,
126
116
  accountId: "account-abc",
127
117
  dedupId: "SYNC_MAILBOXES:scheduled:account-abc:12345",
@@ -66,7 +66,7 @@ export const buildScheduledSyncDedupId = (
66
66
  };
67
67
 
68
68
  export const buildSyncMailboxesCommand = (
69
- input: TriggerAccountSyncInput,
69
+ input: Omit<TriggerAccountSyncInput, "sqsClient">,
70
70
  ): SendMessageCommand => {
71
71
  const { queueUrl, accountId, dedupId, explicitRequest } = input;
72
72
  const event: SyncMailboxesEvent = {