@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.
- package/README.md +100 -0
- package/build.mjs +17 -0
- package/package.json +50 -0
- package/src/account-check.test.ts +122 -0
- package/src/account-check.ts +88 -0
- package/src/body-sync-gate.test.ts +185 -0
- package/src/body-sync-gate.ts +86 -0
- package/src/cli.ts +211 -0
- package/src/connection-scope.test.ts +266 -0
- package/src/connection-scope.ts +335 -0
- package/src/e2e-processor-shim.ts +248 -0
- package/src/emit.test.ts +44 -0
- package/src/emit.ts +142 -0
- package/src/events.ts +221 -0
- package/src/handlers/append-sent-message.ts +163 -0
- package/src/handlers/delete-account-objects.test.ts +81 -0
- package/src/handlers/delete-account-objects.ts +116 -0
- package/src/handlers/empty-trash.ts +136 -0
- package/src/handlers/flag-push.test.ts +25 -0
- package/src/handlers/flag-push.ts +224 -0
- package/src/handlers/mailbox-management.ts +266 -0
- package/src/handlers/mailbox-sync-order.test.ts +93 -0
- package/src/handlers/mailbox-sync-order.ts +65 -0
- package/src/handlers/message-copy.ts +219 -0
- package/src/handlers/message-delete.test.ts +176 -0
- package/src/handlers/message-delete.ts +283 -0
- package/src/handlers/message-move.test.ts +168 -0
- package/src/handlers/message-move.ts +298 -0
- package/src/handlers/placement-move-push.test.ts +234 -0
- package/src/handlers/placement-move-push.ts +434 -0
- package/src/handlers/sync-mailboxes.ts +241 -0
- package/src/handlers/sync-message-body.test.ts +375 -0
- package/src/handlers/sync-message-body.ts +337 -0
- package/src/handlers/sync-messages-deleted-account.test.ts +141 -0
- package/src/handlers/sync-messages.test.ts +204 -0
- package/src/handlers/sync-messages.ts +412 -0
- package/src/handlers/sync-reserved-host.test.ts +97 -0
- package/src/index.test.ts +22 -0
- package/src/index.ts +70 -0
- package/src/poller.ts +49 -0
- package/src/processor.test.ts +58 -0
- package/src/processor.ts +66 -0
- package/src/scheduler/config.test.ts +40 -0
- package/src/scheduler/config.ts +52 -0
- package/src/scheduler/decide-due.test.ts +44 -0
- package/src/scheduler/decide-due.ts +26 -0
- package/src/scheduler/handler.ts +52 -0
- package/src/scheduler/local-runner.ts +76 -0
- package/src/scheduler/run-tick.test.ts +248 -0
- package/src/scheduler/run-tick.ts +141 -0
- package/src/with-oauth-lifecycle-deps.ts +62 -0
- package/src/with-oauth-lifecycle.test.ts +227 -0
- package/src/with-oauth-lifecycle.ts +125 -0
- 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
|
+
};
|