@remit/imap-worker 0.0.63 → 0.0.65

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/imap-worker",
3
- "version": "0.0.63",
3
+ "version": "0.0.65",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -25,6 +25,7 @@
25
25
  "dev": "node --import tsx src/e2e-processor-shim.ts"
26
26
  },
27
27
  "devDependencies": {
28
+ "@remit/queue-sidecar": "*",
28
29
  "@remit/storage-service": "*",
29
30
  "aws-sdk-client-mock": "*",
30
31
  "esbuild": "^0.28.1"
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Regression test for issue #1120.
3
+ *
4
+ * A sync triggered for an account that stores no credential used to fail the
5
+ * batch item, so the poller left the message undeleted for its whole
6
+ * visibility timeout. `remit-mailboxes.fifo` groups by accountId and a FIFO
7
+ * queue never delivers past an in-flight message in a group, so the account
8
+ * was fenced from every later sync — including the one the user asks for right
9
+ * after saving the password.
10
+ *
11
+ * Runs against the real QueueStore and the real queue definition the stack
12
+ * ships, with the poller's own contract modelled around it: ack deletes the
13
+ * message, a batch item failure leaves it in flight.
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { after, describe, it } from "node:test";
20
+ import { fileURLToPath } from "node:url";
21
+ import { buildSyncMailboxesCommand } from "@remit/backend/trigger-sync";
22
+ import type { AccountItem } from "@remit/data-ports";
23
+ import { ConnectionState } from "@remit/domain-enums";
24
+ import {
25
+ bootstrapQueues,
26
+ loadQueuesConfig,
27
+ QueueStore,
28
+ } from "@remit/queue-sidecar";
29
+ import { DEFAULT_VISIBILITY_TIMEOUT_SECONDS } from "@remit/sqs-client/poller";
30
+ import {
31
+ type OAuthLifecycleDeps,
32
+ withOAuthLifecycle,
33
+ } from "./with-oauth-lifecycle.js";
34
+
35
+ const QUEUE_NAME = "remit-mailboxes.fifo";
36
+ const QUEUE_URL = `http://localhost:9324/000000000000/${QUEUE_NAME}`;
37
+ const ACCOUNT_ID = "acct_imported";
38
+ const T0 = 1_700_000_000_000;
39
+
40
+ const here = fileURLToPath(new URL(".", import.meta.url));
41
+ const repoRoot = join(here, "..", "..", "..");
42
+ const tmpRoot = join(here, "..", ".tmp", "credential-less-sync-fence");
43
+
44
+ const silentLogger = {
45
+ info: () => {},
46
+ warn: () => {},
47
+ error: () => {},
48
+ debug: () => {},
49
+ trace: () => {},
50
+ fatal: () => {},
51
+ child: () => silentLogger,
52
+ } as never;
53
+
54
+ /** The account the import wizard lands: no password until its owner types one. */
55
+ const importedAccount = {
56
+ accountId: ACCOUNT_ID,
57
+ accountConfigId: "acfg_1",
58
+ username: "alice@example.com",
59
+ email: "alice@example.com",
60
+ authType: "password",
61
+ imapHost: "imap.example.com",
62
+ imapPort: 993,
63
+ imapTls: true,
64
+ imapStartTls: false,
65
+ isActive: true,
66
+ connectionState: ConnectionState.CredentialsMissing,
67
+ createdAt: T0,
68
+ updatedAt: T0,
69
+ } as unknown as AccountItem;
70
+
71
+ const enqueueSync = (store: QueueStore, now: number): void => {
72
+ const command = buildSyncMailboxesCommand({
73
+ queueUrl: QUEUE_URL,
74
+ accountId: ACCOUNT_ID,
75
+ });
76
+ store.sendMessage({
77
+ queueName: QUEUE_NAME,
78
+ body: command.input.MessageBody ?? "",
79
+ groupId: command.input.MessageGroupId,
80
+ deduplicationId: command.input.MessageDeduplicationId,
81
+ now,
82
+ });
83
+ };
84
+
85
+ describe("a credential-less sync trigger (issue #1120)", () => {
86
+ after(() => {
87
+ rmSync(tmpRoot, { recursive: true, force: true });
88
+ });
89
+
90
+ it("is acked, leaving the account's queue group deliverable", async () => {
91
+ mkdirSync(tmpRoot, { recursive: true });
92
+ const store = new QueueStore(
93
+ join(mkdtempSync(join(tmpRoot, "store-")), "queue.db"),
94
+ );
95
+ bootstrapQueues(
96
+ store,
97
+ loadQueuesConfig(join(repoRoot, "deploy/vps/queues.json")),
98
+ );
99
+
100
+ const stateUpdates: string[] = [];
101
+ const deps: OAuthLifecycleDeps = {
102
+ secrets: {
103
+ decrypt: async () => "",
104
+ encrypt: async () => ({}) as never,
105
+ },
106
+ tokenService: { getAccessToken: async () => ({}) as never },
107
+ persistRotatedToken: async () => {},
108
+ updateConnectionState: async (_accountId, state) => {
109
+ stateUpdates.push(state);
110
+ },
111
+ };
112
+
113
+ // The GET /config poll while the user is still typing the password.
114
+ enqueueSync(store, T0);
115
+
116
+ const [received] = store.receiveMessages({
117
+ queueName: QUEUE_NAME,
118
+ maxMessages: 1,
119
+ visibilityTimeoutSeconds: DEFAULT_VISIBILITY_TIMEOUT_SECONDS,
120
+ now: T0,
121
+ });
122
+ assert.ok(received, "the trigger is on the queue");
123
+
124
+ // The poller's contract: an ack deletes the message, a batch item failure
125
+ // leaves it in flight until the visibility timeout expires.
126
+ let batchItemFailure: unknown;
127
+ await withOAuthLifecycle(deps, importedAccount, silentLogger, async () => {
128
+ throw new Error("IMAP work must not run without a credential");
129
+ }).then(
130
+ () => store.deleteMessage(QUEUE_NAME, received.receiptHandle),
131
+ (err: unknown) => {
132
+ batchItemFailure = err;
133
+ },
134
+ );
135
+
136
+ // The refresh the user presses after saving the password.
137
+ enqueueSync(store, T0 + 2_000);
138
+ const deliverable = store.receiveMessages({
139
+ queueName: QUEUE_NAME,
140
+ maxMessages: 10,
141
+ now: T0 + 3_000,
142
+ });
143
+
144
+ assert.equal(
145
+ deliverable.length,
146
+ 1,
147
+ "the account's group must stay deliverable, not fenced for the visibility timeout",
148
+ );
149
+ assert.equal(
150
+ batchItemFailure,
151
+ undefined,
152
+ "the event is acked, not retried",
153
+ );
154
+ assert.deepEqual(stateUpdates, [ConnectionState.CredentialsMissing]);
155
+ });
156
+ });
@@ -10,6 +10,7 @@ import {
10
10
  buildMailMessage,
11
11
  renderRawMessage,
12
12
  } from "@remit/smtp-service/message-builder";
13
+ import { attemptBudget } from "@remit/sqs-client/attempt-budget";
13
14
  import { isAccountDeleted } from "../account-check.js";
14
15
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
15
16
  import type { AppendSentMessageEvent } from "../events.js";
@@ -29,23 +30,9 @@ const ROW_SURVIVED_ITS_DELETE =
29
30
  const unfiledAppendRefused = (fullPath: string, error: unknown): string =>
30
31
  `Sent, but not filed: the mail server refused to store a copy in ${fullPath} (${error instanceof Error ? error.message : String(error)}).`;
31
32
 
32
- /**
33
- * Fallback when `APPEND_SENT_MAX_ATTEMPTS` is unset (local dev, unit tests).
34
- * Matches the shared `MAX_RECEIVE_COUNT` every queue's redrive policy uses,
35
- * same pattern as `FLAG_PUSH_MAX_ATTEMPTS` / `BODY_SYNC_MAX_ATTEMPTS`.
36
- */
37
- const DEFAULT_APPEND_SENT_MAX_ATTEMPTS = 3;
38
-
39
33
  export const getAppendSentMaxAttempts = (
40
34
  processEnv: NodeJS.ProcessEnv = process.env,
41
- ): number => {
42
- const raw = processEnv.APPEND_SENT_MAX_ATTEMPTS;
43
- if (!raw) return DEFAULT_APPEND_SENT_MAX_ATTEMPTS;
44
- const parsed = Number.parseInt(raw, 10);
45
- return Number.isFinite(parsed) && parsed > 0
46
- ? parsed
47
- : DEFAULT_APPEND_SENT_MAX_ATTEMPTS;
48
- };
35
+ ): number => attemptBudget("APPEND_SENT_MAX_ATTEMPTS", 3, processEnv);
49
36
 
50
37
  export const APPEND_SENT_MAX_ATTEMPTS = getAppendSentMaxAttempts();
51
38
 
@@ -8,6 +8,7 @@ import {
8
8
  MailboxCursorPausedError,
9
9
  resolveExhaustedFlagPushFailure,
10
10
  } from "@remit/mailbox-service";
11
+ import { attemptBudget } from "@remit/sqs-client/attempt-budget";
11
12
  import { isAccountDeleted } from "../account-check.js";
12
13
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
13
14
  import type { FlagPushEvent } from "../events.js";
@@ -15,24 +16,9 @@ import { isNotFoundError } from "../is-not-found.js";
15
16
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
16
17
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
17
18
 
18
- /**
19
- * Fallback when `FLAG_PUSH_MAX_ATTEMPTS` is unset (local dev, unit tests).
20
- * Matches the shared `MAX_RECEIVE_COUNT` every queue's redrive policy uses
21
- * (`infra/stacks/dev/stacks/remit-queue-stack.ts`), same pattern as
22
- * `BODY_SYNC_MAX_ATTEMPTS` (#1270) / `PLACEMENT_MOVE_MAX_ATTEMPTS` (#1289).
23
- */
24
- const DEFAULT_FLAG_PUSH_MAX_ATTEMPTS = 3;
25
-
26
19
  export const getFlagPushMaxAttempts = (
27
20
  processEnv: NodeJS.ProcessEnv = process.env,
28
- ): number => {
29
- const raw = processEnv.FLAG_PUSH_MAX_ATTEMPTS;
30
- if (!raw) return DEFAULT_FLAG_PUSH_MAX_ATTEMPTS;
31
- const parsed = Number.parseInt(raw, 10);
32
- return Number.isFinite(parsed) && parsed > 0
33
- ? parsed
34
- : DEFAULT_FLAG_PUSH_MAX_ATTEMPTS;
35
- };
21
+ ): number => attemptBudget("FLAG_PUSH_MAX_ATTEMPTS", 3, processEnv);
36
22
 
37
23
  export const FLAG_PUSH_MAX_ATTEMPTS = getFlagPushMaxAttempts();
38
24
 
@@ -9,6 +9,7 @@ import {
9
9
  MailboxCursorPausedError,
10
10
  reconcileStaleMessage,
11
11
  } from "@remit/mailbox-service";
12
+ import { attemptBudget } from "@remit/sqs-client/attempt-budget";
12
13
  import { isAccountDeleted } from "../account-check.js";
13
14
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
14
15
  import type { MessageCopyEvent } from "../events.js";
@@ -31,24 +32,9 @@ const defaultDeps: MessageCopyDeps = {
31
32
  createConnectionScope: createConnectionScopeWithCredentials,
32
33
  };
33
34
 
34
- /**
35
- * Fallback when `MESSAGE_COPY_MAX_ATTEMPTS` is unset (local dev, unit tests).
36
- * Matches the `maxReceiveCount` the message queue's redrive policy uses
37
- * (`remit-messages.fifo`, `deploy/vps/queues.json`), same pattern as
38
- * `MESSAGE_MOVE_MAX_ATTEMPTS`.
39
- */
40
- const DEFAULT_MESSAGE_COPY_MAX_ATTEMPTS = 3;
41
-
42
35
  export const getMessageCopyMaxAttempts = (
43
36
  processEnv: NodeJS.ProcessEnv = process.env,
44
- ): number => {
45
- const raw = processEnv.MESSAGE_COPY_MAX_ATTEMPTS;
46
- if (!raw) return DEFAULT_MESSAGE_COPY_MAX_ATTEMPTS;
47
- const parsed = Number.parseInt(raw, 10);
48
- return Number.isFinite(parsed) && parsed > 0
49
- ? parsed
50
- : DEFAULT_MESSAGE_COPY_MAX_ATTEMPTS;
51
- };
37
+ ): number => attemptBudget("MESSAGE_COPY_MAX_ATTEMPTS", 3, processEnv);
52
38
 
53
39
  export const MESSAGE_COPY_MAX_ATTEMPTS = getMessageCopyMaxAttempts();
54
40
 
@@ -14,6 +14,7 @@ import {
14
14
  isMessageGoneFromOpenMailbox,
15
15
  MailboxCursorPausedError,
16
16
  } from "@remit/mailbox-service";
17
+ import { attemptBudget } from "@remit/sqs-client/attempt-budget";
17
18
  import { isAccountDeleted } from "../account-check.js";
18
19
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
19
20
  import { emitEvent } from "../emit.js";
@@ -28,24 +29,9 @@ import {
28
29
  buildThreadMessageTrashUpdate,
29
30
  } from "./thread-message-rows.js";
30
31
 
31
- /**
32
- * Fallback when `MESSAGE_DELETE_MAX_ATTEMPTS` is unset (local dev, unit tests).
33
- * Matches the `maxReceiveCount` the redrive policy of the queue `emit.ts`
34
- * routes MESSAGE_DELETE onto uses (`remit-message-mgmt`,
35
- * `deploy/vps/queues.json`), same pattern as `MESSAGE_MOVE_MAX_ATTEMPTS`.
36
- */
37
- const DEFAULT_MESSAGE_DELETE_MAX_ATTEMPTS = 3;
38
-
39
32
  export const getMessageDeleteMaxAttempts = (
40
33
  processEnv: NodeJS.ProcessEnv = process.env,
41
- ): number => {
42
- const raw = processEnv.MESSAGE_DELETE_MAX_ATTEMPTS;
43
- if (!raw) return DEFAULT_MESSAGE_DELETE_MAX_ATTEMPTS;
44
- const parsed = Number.parseInt(raw, 10);
45
- return Number.isFinite(parsed) && parsed > 0
46
- ? parsed
47
- : DEFAULT_MESSAGE_DELETE_MAX_ATTEMPTS;
48
- };
34
+ ): number => attemptBudget("MESSAGE_DELETE_MAX_ATTEMPTS", 3, processEnv);
49
35
 
50
36
  export const MESSAGE_DELETE_MAX_ATTEMPTS = getMessageDeleteMaxAttempts();
51
37
 
@@ -10,6 +10,7 @@ import {
10
10
  isPlacementUnsettled,
11
11
  MailboxCursorPausedError,
12
12
  } from "@remit/mailbox-service";
13
+ import { attemptBudget } from "@remit/sqs-client/attempt-budget";
13
14
  import { isAccountDeleted } from "../account-check.js";
14
15
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
15
16
  import { emitEvent } from "../emit.js";
@@ -19,24 +20,9 @@ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
19
20
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
20
21
  import { resolveExhaustedMessageMoveFailure } from "./message-move-terminal.js";
21
22
 
22
- /**
23
- * Fallback when `MESSAGE_MOVE_MAX_ATTEMPTS` is unset (local dev, unit tests).
24
- * Matches the `maxReceiveCount` the message queue's redrive policy uses
25
- * (`remit-messages.fifo`, `deploy/vps/queues.json`), same pattern as
26
- * `FLAG_PUSH_MAX_ATTEMPTS` and `PLACEMENT_MOVE_MAX_ATTEMPTS`.
27
- */
28
- const DEFAULT_MESSAGE_MOVE_MAX_ATTEMPTS = 3;
29
-
30
23
  export const getMessageMoveMaxAttempts = (
31
24
  processEnv: NodeJS.ProcessEnv = process.env,
32
- ): number => {
33
- const raw = processEnv.MESSAGE_MOVE_MAX_ATTEMPTS;
34
- if (!raw) return DEFAULT_MESSAGE_MOVE_MAX_ATTEMPTS;
35
- const parsed = Number.parseInt(raw, 10);
36
- return Number.isFinite(parsed) && parsed > 0
37
- ? parsed
38
- : DEFAULT_MESSAGE_MOVE_MAX_ATTEMPTS;
39
- };
25
+ ): number => attemptBudget("MESSAGE_MOVE_MAX_ATTEMPTS", 3, processEnv);
40
26
 
41
27
  export const MESSAGE_MOVE_MAX_ATTEMPTS = getMessageMoveMaxAttempts();
42
28
 
@@ -10,6 +10,7 @@ import {
10
10
  reconcileStaleMessage,
11
11
  resolveExhaustedPlacementMoveFailure,
12
12
  } from "@remit/mailbox-service";
13
+ import { attemptBudget } from "@remit/sqs-client/attempt-budget";
13
14
  import { isAccountDeleted } from "../account-check.js";
14
15
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
15
16
  import { emitEvent } from "../emit.js";
@@ -23,24 +24,9 @@ import {
23
24
  searchMailboxByMessageId,
24
25
  } from "./message-move.js";
25
26
 
26
- /**
27
- * Fallback when `PLACEMENT_MOVE_MAX_ATTEMPTS` is unset (local dev, unit
28
- * tests). Matches the placement-move queue's own `MAX_RECEIVE_COUNT` default
29
- * (`infra/stacks/dev/stacks/remit-queue-stack.ts`), same pattern as
30
- * `BODY_SYNC_MAX_ATTEMPTS` (#1270).
31
- */
32
- const DEFAULT_PLACEMENT_MOVE_MAX_ATTEMPTS = 3;
33
-
34
27
  export const getPlacementMoveMaxAttempts = (
35
28
  processEnv: NodeJS.ProcessEnv = process.env,
36
- ): number => {
37
- const raw = processEnv.PLACEMENT_MOVE_MAX_ATTEMPTS;
38
- if (!raw) return DEFAULT_PLACEMENT_MOVE_MAX_ATTEMPTS;
39
- const parsed = Number.parseInt(raw, 10);
40
- return Number.isFinite(parsed) && parsed > 0
41
- ? parsed
42
- : DEFAULT_PLACEMENT_MOVE_MAX_ATTEMPTS;
43
- };
29
+ ): number => attemptBudget("PLACEMENT_MOVE_MAX_ATTEMPTS", 3, processEnv);
44
30
 
45
31
  export const PLACEMENT_MOVE_MAX_ATTEMPTS = getPlacementMoveMaxAttempts();
46
32
 
@@ -11,6 +11,7 @@ import {
11
11
  QuarantineService,
12
12
  resolveExhaustedBodySyncFailures,
13
13
  } from "@remit/mailbox-service";
14
+ import { attemptBudget } from "@remit/sqs-client/attempt-budget";
14
15
  import { env } from "expect-env";
15
16
  import { isAccountDeleted } from "../account-check.js";
16
17
  import { isBodySyncEnabled } from "../body-sync-gate.js";
@@ -27,37 +28,9 @@ import { workerVersion } from "../worker-version.js";
27
28
 
28
29
  const bodySyncEnabledParameterName = env.BODY_SYNC_ENABLED_PARAMETER_NAME;
29
30
 
30
- /**
31
- * Fallback when `BODY_SYNC_MAX_ATTEMPTS` is unset (local dev, unit tests).
32
- * Matches the body queue's own `MAX_RECEIVE_COUNT` default
33
- * (`infra/stacks/dev/stacks/remit-queue-stack.ts`) so an environment that
34
- * never injects the var still behaves like production.
35
- */
36
- const DEFAULT_BODY_SYNC_MAX_ATTEMPTS = 3;
37
-
38
- /**
39
- * Reads the redelivery-budget-exhaustion threshold. CDK derives
40
- * `BODY_SYNC_MAX_ATTEMPTS` from the body queue's own `MAX_RECEIVE_COUNT`
41
- * (`remit-worker-stack.ts`) so the two constants can't drift apart — a
42
- * hand-copied duplicate here previously risked the worker resolving
43
- * "last attempt" on a different delivery than the queue's redrive policy
44
- * actually uses (issue #1270). SQS's own `ApproximateReceiveCount` is the
45
- * source of truth for how many times a record has been delivered; once it
46
- * reaches this value, the current invocation is the last attempt before the
47
- * queue's own redrive would DLQ the record, so retry exhaustion is resolved
48
- * here (see `resolveExhaustedBodySyncFailures`) instead of letting the
49
- * record dead-letter with no diagnosis.
50
- */
51
31
  export const getBodySyncMaxAttempts = (
52
32
  processEnv: NodeJS.ProcessEnv = process.env,
53
- ): number => {
54
- const raw = processEnv.BODY_SYNC_MAX_ATTEMPTS;
55
- if (!raw) return DEFAULT_BODY_SYNC_MAX_ATTEMPTS;
56
- const parsed = Number.parseInt(raw, 10);
57
- return Number.isFinite(parsed) && parsed > 0
58
- ? parsed
59
- : DEFAULT_BODY_SYNC_MAX_ATTEMPTS;
60
- };
33
+ ): number => attemptBudget("BODY_SYNC_MAX_ATTEMPTS", 3, processEnv);
61
34
 
62
35
  export const BODY_SYNC_MAX_ATTEMPTS = getBodySyncMaxAttempts();
63
36
 
@@ -51,6 +51,7 @@ const baseAccount = (overrides: Partial<AccountItem>): AccountItem =>
51
51
  username: "user",
52
52
  email: "user@example.com",
53
53
  authType: "password",
54
+ passwordHash: '{"ciphertext":"x"}',
54
55
  imapHost: "imap.example.com",
55
56
  imapPort: 993,
56
57
  imapTls: true,
@@ -162,6 +163,36 @@ describe("runSchedulerTick", () => {
162
163
  assert.equal(sent.length, 0);
163
164
  });
164
165
 
166
+ it("skips an account storing no credential (issue #1120)", async () => {
167
+ const due = baseAccount({ accountId: "acct_due" });
168
+ const credentialless = baseAccount({
169
+ accountId: "acct_no_credential",
170
+ passwordHash: undefined,
171
+ });
172
+
173
+ const accountService = fakeAccountService([
174
+ { items: [due, credentialless], cursor: null },
175
+ ]);
176
+ const { sqsClient, sent } = fakeSqsClient();
177
+
178
+ const result = await runSchedulerTick({
179
+ accountService,
180
+ sqsClient,
181
+ queueUrl:
182
+ "https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
183
+ log: createNoopLogger(),
184
+ tickIntervalMs: TICK_INTERVAL_MS,
185
+ offlineIntervalMs: OFFLINE_INTERVAL_MS,
186
+ now: NOW,
187
+ });
188
+
189
+ assert.equal(result.enqueued, 1);
190
+ assert.equal(result.skipped, 1);
191
+ assert.equal(sent.length, 1);
192
+ const body = JSON.parse(sent[0]?.input.MessageBody ?? "{}");
193
+ assert.equal(body.accountId, "acct_due");
194
+ });
195
+
165
196
  it("never logs per-account for ineligible accounts — only the aggregate tick summary (review #1250)", async () => {
166
197
  const deleted = baseAccount({ accountId: "acct_deleted", deletedAt: NOW });
167
198
  const unsyncable = baseAccount({
@@ -5,6 +5,7 @@ import {
5
5
  } from "@remit/backend/trigger-sync";
6
6
  import type { AccountItem, IAccountRepository } from "@remit/data-ports";
7
7
  import type { Logger } from "@remit/logger-lambda";
8
+ import { hasStoredCredential } from "@remit/mailbox-service/account-credentials";
8
9
  import pMap from "p-map";
9
10
  import {
10
11
  isAccountDeleted,
@@ -79,6 +80,10 @@ const isEligible = (account: AccountItem): boolean => {
79
80
  if (isAccountDeleted(account, silentLogger)) return false;
80
81
  if (isUnsyncableHost(account, silentLogger)) return false;
81
82
  if (isAccountReauthRequired(account, silentLogger)) return false;
83
+ // An account storing no credential can only produce a sync that fails at
84
+ // credential resolution (issue #1120); the tick would enqueue one every
85
+ // tick, forever, for an account the user never finished setting up.
86
+ if (!hasStoredCredential(account)) return false;
82
87
  return true;
83
88
  };
84
89
 
@@ -6,7 +6,7 @@ import { RefreshTokenError } from "@remit/mail-oauth-service";
6
6
  import {
7
7
  MailConnectionError,
8
8
  type MailCredentials,
9
- } from "@remit/mailbox-service";
9
+ } from "@remit/mailbox-service/types";
10
10
  import {
11
11
  type ConnectionStateValue,
12
12
  type OAuthLifecycleDeps,
@@ -75,7 +75,7 @@ const buildDeps = (
75
75
  options.resolveCredentials ??
76
76
  (async () => {
77
77
  recorded.resolveCalls += 1;
78
- return passwordCreds;
78
+ return { status: "resolved", credentials: passwordCreds };
79
79
  }),
80
80
  };
81
81
  // Wrap resolveCredentials to record the call count when a custom one is given.
@@ -210,6 +210,36 @@ describe("withOAuthLifecycle", () => {
210
210
  assert.equal(recorded.stateUpdates.length, 0);
211
211
  });
212
212
 
213
+ it("password account with no passwordHash: ACKs and flips to credentials_missing", async () => {
214
+ const { deps, recorded } = buildDeps();
215
+ delete deps.resolveCredentials;
216
+ const account = buildAccount({ authType: AccountAuthType.Password });
217
+
218
+ await withOAuthLifecycle(deps, account, silentLogger, async () => {
219
+ recorded.workCalls += 1;
220
+ });
221
+
222
+ assert.equal(recorded.workCalls, 0, "no IMAP traffic without a credential");
223
+ assert.deepEqual(recorded.stateUpdates, [
224
+ { accountId: "acc-1", state: ConnectionState.CredentialsMissing },
225
+ ]);
226
+ });
227
+
228
+ it("OAuth account with no refresh token: ACKs and flips to reauth_required", async () => {
229
+ const { deps, recorded } = buildDeps();
230
+ delete deps.resolveCredentials;
231
+ const account = buildAccount({ authType: AccountAuthType.OauthMicrosoft });
232
+
233
+ await withOAuthLifecycle(deps, account, silentLogger, async () => {
234
+ recorded.workCalls += 1;
235
+ });
236
+
237
+ assert.equal(recorded.workCalls, 0, "no IMAP traffic without a credential");
238
+ assert.deepEqual(recorded.stateUpdates, [
239
+ { accountId: "acc-1", state: ConnectionState.ReauthRequired },
240
+ ]);
241
+ });
242
+
213
243
  it("on RefreshTokenError transient: rethrows", async () => {
214
244
  const { deps, recorded } = buildDeps({
215
245
  resolveCredentials: async () => {
@@ -8,6 +8,11 @@
8
8
  * - Credential resolution AND the per-handler work both run inside one
9
9
  * try/catch so that a revoked OAuth token is caught regardless of where it
10
10
  * surfaces (token mint vs. first IMAP command).
11
+ * - An account storing no credential at all is terminal, not transient: the
12
+ * account is flipped to the state that describes it and the event is ACKed.
13
+ * Retrying instead fenced the account's FIFO group for the message's whole
14
+ * visibility timeout, blocking the sync that follows the credential the
15
+ * user was still typing (issue #1120).
11
16
  * - On a terminal auth failure (RefreshTokenError reauth-required, or
12
17
  * MailConnectionError auth), flip the account to reauth_required and return
13
18
  * WITHOUT rethrowing — this ACKs the SQS message so it is not retried.
@@ -23,15 +28,17 @@ import type { Logger } from "@remit/logger-lambda";
23
28
  import { RefreshTokenError } from "@remit/mail-oauth-service";
24
29
  import {
25
30
  type AccountCredentialsDeps,
31
+ type ConnectionStateValue,
32
+ type CredentialResolution,
33
+ resolveConnectionCredentials,
34
+ } from "@remit/mailbox-service/account-credentials";
35
+ import {
26
36
  MailConnectionError,
27
37
  type MailCredentials,
28
- resolveConnectionCredentials,
29
- } from "@remit/mailbox-service";
38
+ } from "@remit/mailbox-service/types";
30
39
  import { isAccountReauthRequired } from "./account-check.js";
31
40
 
32
- /** ConnectionState is a const object (not a TS enum); this is its value type. */
33
- export type ConnectionStateValue =
34
- (typeof ConnectionState)[keyof typeof ConnectionState];
41
+ export type { ConnectionStateValue };
35
42
 
36
43
  export interface OAuthLifecycleDeps extends AccountCredentialsDeps {
37
44
  /**
@@ -50,7 +57,7 @@ export interface OAuthLifecycleDeps extends AccountCredentialsDeps {
50
57
  resolveCredentials?: (
51
58
  account: AccountItem,
52
59
  deps: AccountCredentialsDeps,
53
- ) => Promise<MailCredentials>;
60
+ ) => Promise<CredentialResolution>;
54
61
  }
55
62
 
56
63
  /**
@@ -73,8 +80,23 @@ export const withOAuthLifecycle = async (
73
80
  const resolve = deps.resolveCredentials ?? resolveConnectionCredentials;
74
81
 
75
82
  try {
76
- const credentials = await resolve(account, deps);
77
- await work(credentials);
83
+ const resolution = await resolve(account, deps);
84
+ if (resolution.status === "missing") {
85
+ log.warn(
86
+ {
87
+ accountId: account.accountId,
88
+ reason: resolution.reason,
89
+ connectionState: resolution.terminalState,
90
+ },
91
+ "Account stores no credential; acking the event without IMAP traffic",
92
+ );
93
+ await deps.updateConnectionState(
94
+ account.accountId,
95
+ resolution.terminalState,
96
+ );
97
+ return; // ACK — no retry can produce a credential
98
+ }
99
+ await work(resolution.credentials);
78
100
  } catch (err) {
79
101
  // Terminal OAuth failure: token revoked / consent withdrawn.
80
102
  if (err instanceof RefreshTokenError) {