@remit/imap-worker 0.0.1

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.
Files changed (54) hide show
  1. package/README.md +100 -0
  2. package/build.mjs +17 -0
  3. package/package.json +50 -0
  4. package/src/account-check.test.ts +122 -0
  5. package/src/account-check.ts +88 -0
  6. package/src/body-sync-gate.test.ts +185 -0
  7. package/src/body-sync-gate.ts +86 -0
  8. package/src/cli.ts +211 -0
  9. package/src/connection-scope.test.ts +266 -0
  10. package/src/connection-scope.ts +335 -0
  11. package/src/e2e-processor-shim.ts +248 -0
  12. package/src/emit.test.ts +44 -0
  13. package/src/emit.ts +142 -0
  14. package/src/events.ts +221 -0
  15. package/src/handlers/append-sent-message.ts +163 -0
  16. package/src/handlers/delete-account-objects.test.ts +81 -0
  17. package/src/handlers/delete-account-objects.ts +116 -0
  18. package/src/handlers/empty-trash.ts +136 -0
  19. package/src/handlers/flag-push.test.ts +25 -0
  20. package/src/handlers/flag-push.ts +224 -0
  21. package/src/handlers/mailbox-management.ts +266 -0
  22. package/src/handlers/mailbox-sync-order.test.ts +93 -0
  23. package/src/handlers/mailbox-sync-order.ts +65 -0
  24. package/src/handlers/message-copy.ts +219 -0
  25. package/src/handlers/message-delete.test.ts +176 -0
  26. package/src/handlers/message-delete.ts +283 -0
  27. package/src/handlers/message-move.test.ts +168 -0
  28. package/src/handlers/message-move.ts +298 -0
  29. package/src/handlers/placement-move-push.test.ts +234 -0
  30. package/src/handlers/placement-move-push.ts +434 -0
  31. package/src/handlers/sync-mailboxes.ts +241 -0
  32. package/src/handlers/sync-message-body.test.ts +375 -0
  33. package/src/handlers/sync-message-body.ts +337 -0
  34. package/src/handlers/sync-messages-deleted-account.test.ts +141 -0
  35. package/src/handlers/sync-messages.test.ts +204 -0
  36. package/src/handlers/sync-messages.ts +412 -0
  37. package/src/handlers/sync-reserved-host.test.ts +97 -0
  38. package/src/index.test.ts +22 -0
  39. package/src/index.ts +70 -0
  40. package/src/poller.ts +49 -0
  41. package/src/processor.test.ts +58 -0
  42. package/src/processor.ts +66 -0
  43. package/src/scheduler/config.test.ts +40 -0
  44. package/src/scheduler/config.ts +52 -0
  45. package/src/scheduler/decide-due.test.ts +44 -0
  46. package/src/scheduler/decide-due.ts +26 -0
  47. package/src/scheduler/handler.ts +52 -0
  48. package/src/scheduler/local-runner.ts +76 -0
  49. package/src/scheduler/run-tick.test.ts +248 -0
  50. package/src/scheduler/run-tick.ts +141 -0
  51. package/src/with-oauth-lifecycle-deps.ts +62 -0
  52. package/src/with-oauth-lifecycle.test.ts +227 -0
  53. package/src/with-oauth-lifecycle.ts +125 -0
  54. package/tsconfig.json +8 -0
@@ -0,0 +1,141 @@
1
+ import type { SQSClient } from "@aws-sdk/client-sqs";
2
+ import {
3
+ buildScheduledSyncDedupId,
4
+ triggerAccountSync,
5
+ } from "@remit/backend/trigger-sync";
6
+ import type { AccountItem, IAccountRepository } from "@remit/data-ports";
7
+ import type { Logger } from "@remit/logger-lambda";
8
+ import pMap from "p-map";
9
+ import {
10
+ isAccountDeleted,
11
+ isAccountReauthRequired,
12
+ isUnsyncableHost,
13
+ } from "../account-check.js";
14
+ import {
15
+ SCHEDULER_ENQUEUE_CONCURRENCY,
16
+ SCHEDULER_PAGE_SIZE,
17
+ } from "./config.js";
18
+ import { isSyncDue } from "./decide-due.js";
19
+
20
+ export interface RunSchedulerTickDeps {
21
+ accountService: Pick<IAccountRepository, "listAllAccountsPage">;
22
+ sqsClient: SQSClient;
23
+ queueUrl: string;
24
+ log: Logger;
25
+ offlineIntervalMs: number;
26
+ /**
27
+ * How often this tick itself runs — the dedup-id bucket width for
28
+ * `buildScheduledSyncDedupId`, so consecutive ticks each get a fresh id.
29
+ */
30
+ tickIntervalMs: number;
31
+ /** Injectable for tests; defaults to `Date.now()`. */
32
+ now?: number;
33
+ }
34
+
35
+ export interface SchedulerTickResult {
36
+ scanned: number;
37
+ enqueued: number;
38
+ skipped: number;
39
+ }
40
+
41
+ /**
42
+ * `isAccountDeleted` / `isUnsyncableHost` / `isAccountReauthRequired` each log
43
+ * one line per ineligible account — the right volume for their real call site
44
+ * (once per SYNC_MAILBOXES event). Run across the whole account base every
45
+ * tick, that becomes one log line per deleted/reauth/placeholder account
46
+ * every 5 minutes, forever (review #1250). The tick already reports the
47
+ * aggregate `skipped` count, so eligibility checks here go through a silent
48
+ * logger — the per-event path (sync-mailboxes.ts) is untouched and keeps
49
+ * logging normally.
50
+ */
51
+ const silentLogger: Logger = (() => {
52
+ const noop = () => {};
53
+ const stub = {
54
+ info: noop,
55
+ warn: noop,
56
+ error: noop,
57
+ debug: noop,
58
+ fatal: noop,
59
+ trace: noop,
60
+ child: () => stub,
61
+ };
62
+ return stub as unknown as Logger;
63
+ })();
64
+
65
+ const isEligible = (account: AccountItem): boolean => {
66
+ if (isAccountDeleted(account, silentLogger)) return false;
67
+ if (isUnsyncableHost(account, silentLogger)) return false;
68
+ if (isAccountReauthRequired(account, silentLogger)) return false;
69
+ return true;
70
+ };
71
+
72
+ /**
73
+ * One tick of the periodic mailbox-sync scheduler (#1247, restructured
74
+ * #1251). Pages through every account (never loading the whole account base
75
+ * into memory), decides per account via `isSyncDue` against the single
76
+ * offline threshold, and enqueues SYNC_MAILBOXES for the accounts that are
77
+ * due — in bounded-concurrency batches, never an unbounded `Promise.all`.
78
+ *
79
+ * Every enqueue goes through the same `triggerAccountSync` the manual
80
+ * POST /sync path uses, with a scheduler-specific, time-bucketed dedup id
81
+ * (`buildScheduledSyncDedupId`, bucketed by `tickIntervalMs`) so this tick
82
+ * can never collide with its own previous tick or with a concurrent manual
83
+ * trigger (see trigger-sync.ts). Concurrent-sync safety for the mailbox
84
+ * itself is MailboxLockService's job, inside the worker handler — this tick
85
+ * only decides "is a sync due" and enqueues; it never talks to IMAP.
86
+ */
87
+ export const runSchedulerTick = async (
88
+ deps: RunSchedulerTickDeps,
89
+ ): Promise<SchedulerTickResult> => {
90
+ const {
91
+ accountService,
92
+ sqsClient,
93
+ queueUrl,
94
+ log,
95
+ offlineIntervalMs,
96
+ tickIntervalMs,
97
+ } = deps;
98
+ const now = deps.now ?? Date.now();
99
+
100
+ let cursor: string | undefined;
101
+ let scanned = 0;
102
+ let enqueued = 0;
103
+ let skipped = 0;
104
+
105
+ do {
106
+ const page = await accountService.listAllAccountsPage({
107
+ limit: SCHEDULER_PAGE_SIZE,
108
+ cursor,
109
+ });
110
+ scanned += page.items.length;
111
+
112
+ const due = page.items.filter(
113
+ (account) =>
114
+ isEligible(account) && isSyncDue(account, now, offlineIntervalMs),
115
+ );
116
+ skipped += page.items.length - due.length;
117
+
118
+ await pMap(
119
+ due,
120
+ (account) =>
121
+ triggerAccountSync({
122
+ sqsClient,
123
+ queueUrl,
124
+ accountId: account.accountId,
125
+ dedupId: buildScheduledSyncDedupId(
126
+ account.accountId,
127
+ now,
128
+ tickIntervalMs,
129
+ ),
130
+ }),
131
+ { concurrency: SCHEDULER_ENQUEUE_CONCURRENCY },
132
+ );
133
+ enqueued += due.length;
134
+
135
+ cursor = page.cursor ?? undefined;
136
+ } while (cursor);
137
+
138
+ log.info({ scanned, enqueued, skipped }, "Scheduled-sync tick complete");
139
+
140
+ return { scanned, enqueued, skipped };
141
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * with-oauth-lifecycle-deps.ts
3
+ *
4
+ * Shared construction of the deps passed to withOAuthLifecycle by every IMAP
5
+ * handler. Centralizes the lazy OAuth token service, secrets, refresh-token
6
+ * rotation persistence, and the connectionState writer so handlers don't each
7
+ * re-declare the same boilerplate.
8
+ */
9
+
10
+ import type { IAccountRepository } from "@remit/data-ports";
11
+ import {
12
+ createMailOAuthService,
13
+ microsoftProviderConfig,
14
+ } from "@remit/mail-oauth-service";
15
+ import type { SecretsService } from "@remit/secrets-service";
16
+ import type {
17
+ ConnectionStateValue,
18
+ OAuthLifecycleDeps,
19
+ } from "./with-oauth-lifecycle.js";
20
+
21
+ // Lazy OAuth service: created on first OAuth account.
22
+ // Uses MSOAUTH_* env vars which are only present in deployed Lambdas.
23
+ let _tokenService: ReturnType<typeof createMailOAuthService> | undefined;
24
+ const getTokenService = (): ReturnType<typeof createMailOAuthService> => {
25
+ if (!_tokenService) {
26
+ _tokenService = createMailOAuthService(
27
+ microsoftProviderConfig({
28
+ clientId: process.env.MSOAUTH_CLIENT_ID ?? "",
29
+ clientSecret: process.env.MSOAUTH_CLIENT_SECRET ?? "",
30
+ overrides: process.env.MSOAUTH_TOKEN_ENDPOINT
31
+ ? { tokenEndpoint: process.env.MSOAUTH_TOKEN_ENDPOINT }
32
+ : undefined,
33
+ }),
34
+ );
35
+ }
36
+ return _tokenService;
37
+ };
38
+
39
+ /**
40
+ * Build the OAuthLifecycleDeps for a handler. `secrets` and `accountService`
41
+ * are the long-lived singletons already constructed at module scope in each
42
+ * handler.
43
+ */
44
+ export const buildLifecycleDeps = (
45
+ secrets: SecretsService,
46
+ accountService: IAccountRepository,
47
+ ): OAuthLifecycleDeps => ({
48
+ secrets,
49
+ tokenService: getTokenService(),
50
+ persistRotatedToken: async (accountId, encryptedHash, updatedAt) => {
51
+ await accountService.update(accountId, {
52
+ oauthRefreshTokenHash: encryptedHash,
53
+ oauthTokenUpdatedAt: updatedAt,
54
+ });
55
+ },
56
+ updateConnectionState: async (
57
+ accountId: string,
58
+ state: ConnectionStateValue,
59
+ ) => {
60
+ await accountService.update(accountId, { connectionState: state });
61
+ },
62
+ });
@@ -0,0 +1,227 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { AccountItem } from "@remit/data-ports";
4
+ import { AccountAuthType, ConnectionState } from "@remit/domain-enums";
5
+ import { RefreshTokenError } from "@remit/mail-oauth-service";
6
+ import {
7
+ MailConnectionError,
8
+ type MailCredentials,
9
+ } from "@remit/mailbox-service";
10
+ import {
11
+ type ConnectionStateValue,
12
+ type OAuthLifecycleDeps,
13
+ withOAuthLifecycle,
14
+ } from "./with-oauth-lifecycle.js";
15
+
16
+ const silentLogger = {
17
+ info: () => {},
18
+ warn: () => {},
19
+ error: () => {},
20
+ debug: () => {},
21
+ trace: () => {},
22
+ fatal: () => {},
23
+ child: () => silentLogger,
24
+ } as never;
25
+
26
+ const buildAccount = (overrides: Partial<AccountItem> = {}): AccountItem =>
27
+ ({
28
+ accountId: "acc-1",
29
+ accountConfigId: "cfg-1",
30
+ username: "alice@example.com",
31
+ email: "alice@example.com",
32
+ imapHost: "imap.example.com",
33
+ imapPort: 993,
34
+ imapTls: true,
35
+ imapStartTls: false,
36
+ isActive: true,
37
+ connectionState: "not_authenticated",
38
+ createdAt: 0,
39
+ updatedAt: 0,
40
+ ...overrides,
41
+ }) as unknown as AccountItem;
42
+
43
+ interface Recorded {
44
+ stateUpdates: Array<{ accountId: string; state: ConnectionStateValue }>;
45
+ resolveCalls: number;
46
+ workCalls: number;
47
+ }
48
+
49
+ const passwordCreds: MailCredentials = {
50
+ kind: "password",
51
+ password: "secret",
52
+ };
53
+
54
+ const buildDeps = (
55
+ options: {
56
+ resolveCredentials?: OAuthLifecycleDeps["resolveCredentials"];
57
+ } = {},
58
+ ): { deps: OAuthLifecycleDeps; recorded: Recorded } => {
59
+ const recorded: Recorded = {
60
+ stateUpdates: [],
61
+ resolveCalls: 0,
62
+ workCalls: 0,
63
+ };
64
+ const deps: OAuthLifecycleDeps = {
65
+ secrets: {
66
+ decrypt: async () => "",
67
+ encrypt: async () => ({}) as never,
68
+ },
69
+ tokenService: { getAccessToken: async () => ({}) as never },
70
+ persistRotatedToken: async () => {},
71
+ updateConnectionState: async (accountId, state) => {
72
+ recorded.stateUpdates.push({ accountId, state });
73
+ },
74
+ resolveCredentials:
75
+ options.resolveCredentials ??
76
+ (async () => {
77
+ recorded.resolveCalls += 1;
78
+ return passwordCreds;
79
+ }),
80
+ };
81
+ // Wrap resolveCredentials to record the call count when a custom one is given.
82
+ if (options.resolveCredentials) {
83
+ const inner = options.resolveCredentials;
84
+ deps.resolveCredentials = async (account, credDeps) => {
85
+ recorded.resolveCalls += 1;
86
+ return inner(account, credDeps);
87
+ };
88
+ }
89
+ return { deps, recorded };
90
+ };
91
+
92
+ describe("withOAuthLifecycle", () => {
93
+ it("skips work when account is reauth_required", async () => {
94
+ const { deps, recorded } = buildDeps();
95
+ const account = buildAccount({ connectionState: "reauth_required" });
96
+
97
+ await withOAuthLifecycle(deps, account, silentLogger, async () => {
98
+ recorded.workCalls += 1;
99
+ });
100
+
101
+ assert.equal(recorded.workCalls, 0, "work must not be called");
102
+ assert.equal(recorded.resolveCalls, 0, "must not resolve credentials");
103
+ assert.equal(recorded.stateUpdates.length, 0, "must not update state");
104
+ });
105
+
106
+ it("on RefreshTokenError reauth-required: flips to reauth_required and ACKs (does not rethrow)", async () => {
107
+ const { deps, recorded } = buildDeps({
108
+ resolveCredentials: async () => {
109
+ throw new RefreshTokenError({
110
+ kind: "reauth-required",
111
+ code: "invalid_grant",
112
+ });
113
+ },
114
+ });
115
+ const account = buildAccount();
116
+
117
+ await withOAuthLifecycle(deps, account, silentLogger, async () => {
118
+ recorded.workCalls += 1;
119
+ });
120
+
121
+ assert.equal(
122
+ recorded.workCalls,
123
+ 0,
124
+ "work must not run after resolve fails",
125
+ );
126
+ assert.equal(recorded.stateUpdates.length, 1);
127
+ assert.deepEqual(recorded.stateUpdates[0], {
128
+ accountId: "acc-1",
129
+ state: ConnectionState.ReauthRequired,
130
+ });
131
+ });
132
+
133
+ it("on MailConnectionError auth for OAuth account: flips to reauth_required and ACKs", async () => {
134
+ const { deps, recorded } = buildDeps();
135
+ const account = buildAccount({ authType: AccountAuthType.OauthMicrosoft });
136
+
137
+ await withOAuthLifecycle(deps, account, silentLogger, async () => {
138
+ throw new MailConnectionError("auth", "auth failed");
139
+ });
140
+
141
+ assert.equal(recorded.stateUpdates.length, 1);
142
+ assert.deepEqual(recorded.stateUpdates[0], {
143
+ accountId: "acc-1",
144
+ state: ConnectionState.ReauthRequired,
145
+ });
146
+ });
147
+
148
+ it("on MailConnectionError auth for password account: rethrows (batch item failure, no state flip)", async () => {
149
+ const { deps, recorded } = buildDeps();
150
+ const account = buildAccount({ authType: AccountAuthType.Password });
151
+
152
+ await assert.rejects(
153
+ () =>
154
+ withOAuthLifecycle(deps, account, silentLogger, async () => {
155
+ throw new MailConnectionError("auth", "auth failed");
156
+ }),
157
+ /auth failed/,
158
+ );
159
+ assert.equal(
160
+ recorded.stateUpdates.length,
161
+ 0,
162
+ "must not flip connectionState for password account",
163
+ );
164
+ });
165
+
166
+ it("on MailConnectionError auth for account with no authType (defaults to password): rethrows", async () => {
167
+ const { deps, recorded } = buildDeps();
168
+ // No authType set — defaults to password in resolveConnectionCredentials
169
+ const account = buildAccount();
170
+
171
+ await assert.rejects(
172
+ () =>
173
+ withOAuthLifecycle(deps, account, silentLogger, async () => {
174
+ throw new MailConnectionError("auth", "auth failed");
175
+ }),
176
+ /auth failed/,
177
+ );
178
+ assert.equal(
179
+ recorded.stateUpdates.length,
180
+ 0,
181
+ "must not flip connectionState when authType is unset",
182
+ );
183
+ });
184
+
185
+ it("on transient error (network): rethrows (batch item failure)", async () => {
186
+ const { deps, recorded } = buildDeps();
187
+ const account = buildAccount();
188
+
189
+ await assert.rejects(
190
+ () =>
191
+ withOAuthLifecycle(deps, account, silentLogger, async () => {
192
+ throw new MailConnectionError("network", "timeout");
193
+ }),
194
+ /timeout/,
195
+ );
196
+ assert.equal(recorded.stateUpdates.length, 0, "must not flip state");
197
+ });
198
+
199
+ it("on ordinary Error: rethrows", async () => {
200
+ const { deps, recorded } = buildDeps();
201
+ const account = buildAccount();
202
+
203
+ await assert.rejects(
204
+ () =>
205
+ withOAuthLifecycle(deps, account, silentLogger, async () => {
206
+ throw new Error("boom");
207
+ }),
208
+ /boom/,
209
+ );
210
+ assert.equal(recorded.stateUpdates.length, 0);
211
+ });
212
+
213
+ it("on RefreshTokenError transient: rethrows", async () => {
214
+ const { deps, recorded } = buildDeps({
215
+ resolveCredentials: async () => {
216
+ throw new RefreshTokenError({ kind: "transient", code: "503" });
217
+ },
218
+ });
219
+ const account = buildAccount();
220
+
221
+ await assert.rejects(
222
+ () => withOAuthLifecycle(deps, account, silentLogger, async () => {}),
223
+ /transient/,
224
+ );
225
+ assert.equal(recorded.stateUpdates.length, 0, "must not flip state");
226
+ });
227
+ });
@@ -0,0 +1,125 @@
1
+ /**
2
+ * with-oauth-lifecycle.ts
3
+ *
4
+ * Centralizes the OAuth reauth/ACK contract shared by every IMAP handler.
5
+ *
6
+ * The contract (see issue #472):
7
+ * - If the account already requires reauth, skip all IMAP traffic entirely.
8
+ * - Credential resolution AND the per-handler work both run inside one
9
+ * try/catch so that a revoked OAuth token is caught regardless of where it
10
+ * surfaces (token mint vs. first IMAP command).
11
+ * - On a terminal auth failure (RefreshTokenError reauth-required, or
12
+ * MailConnectionError auth), flip the account to reauth_required and return
13
+ * WITHOUT rethrowing — this ACKs the SQS message so it is not retried.
14
+ * - On transient / config / network errors, rethrow so SQS retries with
15
+ * backoff (let-it-crash).
16
+ *
17
+ * Tokens must NEVER appear in logs — only accountId / errorKind / errorCode.
18
+ */
19
+
20
+ import type { AccountItem } from "@remit/data-ports";
21
+ import { AccountAuthType, ConnectionState } from "@remit/domain-enums";
22
+ import type { Logger } from "@remit/logger-lambda";
23
+ import { RefreshTokenError } from "@remit/mail-oauth-service";
24
+ import {
25
+ type AccountCredentialsDeps,
26
+ MailConnectionError,
27
+ type MailCredentials,
28
+ resolveConnectionCredentials,
29
+ } from "@remit/mailbox-service";
30
+ import { isAccountReauthRequired } from "./account-check.js";
31
+
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];
35
+
36
+ export interface OAuthLifecycleDeps extends AccountCredentialsDeps {
37
+ /**
38
+ * Persist the account's connectionState. Called when a terminal auth
39
+ * failure is detected so the account is fenced off until the user re-auths.
40
+ */
41
+ updateConnectionState: (
42
+ accountId: string,
43
+ state: ConnectionStateValue,
44
+ ) => Promise<void>;
45
+ /**
46
+ * Resolve credentials for the account. Defaults to
47
+ * resolveConnectionCredentials; overridable for testing. Kept as the ONLY
48
+ * authType branch in the codebase (see account-credentials.ts).
49
+ */
50
+ resolveCredentials?: (
51
+ account: AccountItem,
52
+ deps: AccountCredentialsDeps,
53
+ ) => Promise<MailCredentials>;
54
+ }
55
+
56
+ /**
57
+ * Run `work` for an account under the shared OAuth reauth/ACK contract.
58
+ *
59
+ * `work` receives the resolved MailCredentials. Both credential resolution and
60
+ * `work` run inside the same try/catch.
61
+ */
62
+ export const withOAuthLifecycle = async (
63
+ deps: OAuthLifecycleDeps,
64
+ account: AccountItem,
65
+ log: Logger,
66
+ work: (credentials: MailCredentials) => Promise<void>,
67
+ ): Promise<void> => {
68
+ // Skip all IMAP traffic for accounts that already require reauth.
69
+ if (isAccountReauthRequired(account, log)) {
70
+ return;
71
+ }
72
+
73
+ const resolve = deps.resolveCredentials ?? resolveConnectionCredentials;
74
+
75
+ try {
76
+ const credentials = await resolve(account, deps);
77
+ await work(credentials);
78
+ } catch (err) {
79
+ // Terminal OAuth failure: token revoked / consent withdrawn.
80
+ if (err instanceof RefreshTokenError) {
81
+ if (err.error.kind === "reauth-required") {
82
+ log.warn(
83
+ {
84
+ accountId: account.accountId,
85
+ errorKind: err.error.kind,
86
+ errorCode: err.error.code,
87
+ },
88
+ "OAuth token revoked; marking account reauth_required",
89
+ );
90
+ await deps.updateConnectionState(
91
+ account.accountId,
92
+ ConnectionState.ReauthRequired,
93
+ );
94
+ return; // ACK — do not retry
95
+ }
96
+ // transient or config: let-it-crash (SQS retry / DLQ)
97
+ throw err;
98
+ }
99
+
100
+ // Terminal auth failure at the IMAP layer (bad credentials / expired token).
101
+ // Only OAuth accounts can recover via the re-auth flow; password accounts
102
+ // have no such path so we rethrow to preserve pre-PR batch-item-failure
103
+ // behaviour instead of permanently fencing the account.
104
+ if (err instanceof MailConnectionError && err.kind === "auth") {
105
+ if (account.authType !== AccountAuthType.OauthMicrosoft) {
106
+ throw err;
107
+ }
108
+ log.warn(
109
+ {
110
+ accountId: account.accountId,
111
+ errorKind: err.kind,
112
+ },
113
+ "IMAP auth rejected; marking account reauth_required",
114
+ );
115
+ await deps.updateConnectionState(
116
+ account.accountId,
117
+ ConnectionState.ReauthRequired,
118
+ );
119
+ return; // ACK — do not retry
120
+ }
121
+
122
+ // Transient / network / unexpected: rethrow so SQS retries with backoff.
123
+ throw err;
124
+ }
125
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src/**/*.ts"]
8
+ }