@remit/imap-worker 0.0.66 → 0.0.67
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.
|
|
3
|
+
"version": "0.0.67",
|
|
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/drizzle-service": "*",
|
|
28
29
|
"@remit/queue-sidecar": "*",
|
|
29
30
|
"@remit/storage-service": "*",
|
|
30
31
|
"aws-sdk-client-mock": "*",
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the account row holds after the lifecycle has written to it.
|
|
3
|
+
*
|
|
4
|
+
* The deps builder is the half that talks to the store, so it is tested against
|
|
5
|
+
* the real repository over the shipped SQLite schema rather than a stand-in
|
|
6
|
+
* that would only agree with itself: whether a stored reason survives, and
|
|
7
|
+
* whether one that no longer applies is gone, is decided by the repository's
|
|
8
|
+
* update semantics, where an absent field means "leave it alone".
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { after, before, describe, it } from "node:test";
|
|
13
|
+
import type { AccountItem } from "@remit/data-ports";
|
|
14
|
+
import { AccountAuthType, ConnectionState } from "@remit/domain-enums";
|
|
15
|
+
import { AccountRepo } from "@remit/drizzle-service";
|
|
16
|
+
import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
|
|
17
|
+
import { RefreshTokenError } from "@remit/mail-oauth-service";
|
|
18
|
+
import { MailConnectionError } from "@remit/mailbox-service/types";
|
|
19
|
+
import type { SecretsService } from "@remit/secrets-service";
|
|
20
|
+
import {
|
|
21
|
+
type OAuthLifecycleDeps,
|
|
22
|
+
withOAuthLifecycle,
|
|
23
|
+
} from "./with-oauth-lifecycle.js";
|
|
24
|
+
import { buildLifecycleDeps } from "./with-oauth-lifecycle-deps.js";
|
|
25
|
+
|
|
26
|
+
const silentLogger = {
|
|
27
|
+
info: () => {},
|
|
28
|
+
warn: () => {},
|
|
29
|
+
error: () => {},
|
|
30
|
+
debug: () => {},
|
|
31
|
+
trace: () => {},
|
|
32
|
+
fatal: () => {},
|
|
33
|
+
child: () => silentLogger,
|
|
34
|
+
} as never;
|
|
35
|
+
|
|
36
|
+
const secrets = {
|
|
37
|
+
decrypt: async () => "decrypted",
|
|
38
|
+
encrypt: async () => ({}) as never,
|
|
39
|
+
} as unknown as SecretsService;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The real deps, with only credential resolution stubbed — minting a token is
|
|
43
|
+
* a call to Microsoft and not what these tests are about. `updateConnectionState`
|
|
44
|
+
* is the one under test and stays exactly as the handlers build it.
|
|
45
|
+
*/
|
|
46
|
+
const deps = (): OAuthLifecycleDeps => ({
|
|
47
|
+
...buildLifecycleDeps(secrets, repo),
|
|
48
|
+
resolveCredentials: async () => ({
|
|
49
|
+
status: "resolved",
|
|
50
|
+
credentials: { kind: "accessToken", accessToken: "access-token" },
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const STALE_SYNC_ERROR = "Mailbox does not exist: [NONEXISTENT]";
|
|
55
|
+
const REFUSAL =
|
|
56
|
+
"IMAP authentication failed: LOGIN failed. User is authenticated but not connected.";
|
|
57
|
+
|
|
58
|
+
let repo: AccountRepo;
|
|
59
|
+
let close: () => void;
|
|
60
|
+
|
|
61
|
+
const account = async (): Promise<AccountItem> =>
|
|
62
|
+
repo.create({
|
|
63
|
+
accountConfigId: "cfg-1",
|
|
64
|
+
username: "matthijs@example.com",
|
|
65
|
+
email: "matthijs@example.com",
|
|
66
|
+
authType: AccountAuthType.OauthMicrosoft,
|
|
67
|
+
oauthRefreshTokenHash: "encrypted-refresh-token",
|
|
68
|
+
imapHost: "outlook.office365.com",
|
|
69
|
+
imapPort: 993,
|
|
70
|
+
imapTls: true,
|
|
71
|
+
imapStartTls: false,
|
|
72
|
+
isActive: true,
|
|
73
|
+
connectionState: ConnectionState.Authenticated,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
before(() => {
|
|
77
|
+
const db = createShippedSqliteDb();
|
|
78
|
+
close = db.close;
|
|
79
|
+
repo = new AccountRepo(db.db);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
after(() => {
|
|
83
|
+
close();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("the reason an account card reads back", () => {
|
|
87
|
+
it("keeps the refusal the mail server gave", async () => {
|
|
88
|
+
const created = await account();
|
|
89
|
+
|
|
90
|
+
await withOAuthLifecycle(deps(), created, silentLogger, async () => {
|
|
91
|
+
throw new MailConnectionError("auth", REFUSAL);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const stored = await repo.get(created.accountId);
|
|
95
|
+
assert.equal(stored.connectionState, ConnectionState.ReauthRequired);
|
|
96
|
+
assert.equal(stored.lastError, REFUSAL);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("drops a reason the account has moved on from", async () => {
|
|
100
|
+
const created = await account();
|
|
101
|
+
// What a failed sync leaves behind (sync-mailboxes.ts): a folder that went
|
|
102
|
+
// away, nothing to do with signing in.
|
|
103
|
+
await repo.update(created.accountId, { lastError: STALE_SYNC_ERROR });
|
|
104
|
+
|
|
105
|
+
// Hours later the refresh token is revoked. Re-authenticating is now the
|
|
106
|
+
// whole story, and the mailbox that went missing is not it.
|
|
107
|
+
await withOAuthLifecycle(
|
|
108
|
+
deps(),
|
|
109
|
+
await repo.get(created.accountId),
|
|
110
|
+
silentLogger,
|
|
111
|
+
async () => {
|
|
112
|
+
throw new RefreshTokenError({
|
|
113
|
+
kind: "reauth-required",
|
|
114
|
+
code: "invalid_grant",
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const stored = await repo.get(created.accountId);
|
|
120
|
+
assert.equal(stored.connectionState, ConnectionState.ReauthRequired);
|
|
121
|
+
assert.equal(
|
|
122
|
+
stored.lastError,
|
|
123
|
+
undefined,
|
|
124
|
+
"a stale sync error beside a Reconnect button reads as the reason for it",
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("stores a chatty refusal at a length a card can hold", async () => {
|
|
129
|
+
const created = await account();
|
|
130
|
+
|
|
131
|
+
await withOAuthLifecycle(deps(), created, silentLogger, async () => {
|
|
132
|
+
throw new MailConnectionError("auth", "x".repeat(4000));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const stored = await repo.get(created.accountId);
|
|
136
|
+
assert.equal(stored.lastError?.length, 500);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -53,10 +53,19 @@ export const buildLifecycleDeps = (
|
|
|
53
53
|
oauthTokenUpdatedAt: updatedAt,
|
|
54
54
|
});
|
|
55
55
|
},
|
|
56
|
+
// A state flip with no reason of its own removes the stored one. Left in
|
|
57
|
+
// place it would describe a failure the account has moved on from — the
|
|
58
|
+
// mailbox a sync could not find, still on the card beside a Reconnect
|
|
59
|
+
// button, after the token was revoked hours later.
|
|
56
60
|
updateConnectionState: async (
|
|
57
61
|
accountId: string,
|
|
58
62
|
state: ConnectionStateValue,
|
|
63
|
+
lastError?: string,
|
|
59
64
|
) => {
|
|
60
|
-
await accountService.update(
|
|
65
|
+
await accountService.update(
|
|
66
|
+
accountId,
|
|
67
|
+
{ connectionState: state, lastError },
|
|
68
|
+
lastError === undefined ? ["lastError"] : undefined,
|
|
69
|
+
);
|
|
61
70
|
},
|
|
62
71
|
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
2
|
+
import { describe, it, mock } from "node:test";
|
|
3
3
|
import type { AccountItem } from "@remit/data-ports";
|
|
4
4
|
import { AccountAuthType, ConnectionState } from "@remit/domain-enums";
|
|
5
5
|
import { RefreshTokenError } from "@remit/mail-oauth-service";
|
|
6
|
+
import { ImapFlowConnection } from "@remit/mailbox-service/imapflow-connection";
|
|
6
7
|
import {
|
|
7
8
|
MailConnectionError,
|
|
8
9
|
type MailCredentials,
|
|
@@ -41,11 +42,19 @@ const buildAccount = (overrides: Partial<AccountItem> = {}): AccountItem =>
|
|
|
41
42
|
}) as unknown as AccountItem;
|
|
42
43
|
|
|
43
44
|
interface Recorded {
|
|
44
|
-
stateUpdates: Array<{
|
|
45
|
+
stateUpdates: Array<{
|
|
46
|
+
accountId: string;
|
|
47
|
+
state: ConnectionStateValue;
|
|
48
|
+
lastError?: string;
|
|
49
|
+
}>;
|
|
45
50
|
resolveCalls: number;
|
|
46
51
|
workCalls: number;
|
|
47
52
|
}
|
|
48
53
|
|
|
54
|
+
/** What Microsoft says to a mailbox whose tenant has IMAP switched off. */
|
|
55
|
+
const REFUSAL =
|
|
56
|
+
"LOGIN failed. User is authenticated but not connected. SmtpClientAuthentication is disabled for the Tenant.";
|
|
57
|
+
|
|
49
58
|
const passwordCreds: MailCredentials = {
|
|
50
59
|
kind: "password",
|
|
51
60
|
password: "secret",
|
|
@@ -68,8 +77,8 @@ const buildDeps = (
|
|
|
68
77
|
},
|
|
69
78
|
tokenService: { getAccessToken: async () => ({}) as never },
|
|
70
79
|
persistRotatedToken: async () => {},
|
|
71
|
-
updateConnectionState: async (accountId, state) => {
|
|
72
|
-
recorded.stateUpdates.push({ accountId, state });
|
|
80
|
+
updateConnectionState: async (accountId, state, lastError) => {
|
|
81
|
+
recorded.stateUpdates.push({ accountId, state, lastError });
|
|
73
82
|
},
|
|
74
83
|
resolveCredentials:
|
|
75
84
|
options.resolveCredentials ??
|
|
@@ -124,9 +133,12 @@ describe("withOAuthLifecycle", () => {
|
|
|
124
133
|
"work must not run after resolve fails",
|
|
125
134
|
);
|
|
126
135
|
assert.equal(recorded.stateUpdates.length, 1);
|
|
136
|
+
// A revoked token has nothing to add: re-authenticating is the whole of
|
|
137
|
+
// what the account needs, and the card says so on its own.
|
|
127
138
|
assert.deepEqual(recorded.stateUpdates[0], {
|
|
128
139
|
accountId: "acc-1",
|
|
129
140
|
state: ConnectionState.ReauthRequired,
|
|
141
|
+
lastError: undefined,
|
|
130
142
|
});
|
|
131
143
|
});
|
|
132
144
|
|
|
@@ -142,7 +154,48 @@ describe("withOAuthLifecycle", () => {
|
|
|
142
154
|
assert.deepEqual(recorded.stateUpdates[0], {
|
|
143
155
|
accountId: "acc-1",
|
|
144
156
|
state: ConnectionState.ReauthRequired,
|
|
157
|
+
lastError: "auth failed",
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("keeps what the server said when it refused, so the card can repeat it", async () => {
|
|
162
|
+
const { deps, recorded } = buildDeps();
|
|
163
|
+
const account = buildAccount({ authType: AccountAuthType.OauthMicrosoft });
|
|
164
|
+
|
|
165
|
+
// The error an Exchange mailbox with IMAP switched off hands back, built
|
|
166
|
+
// the way imapflow builds it and classified by the code that ships.
|
|
167
|
+
const connection = new ImapFlowConnection({
|
|
168
|
+
host: "outlook.office365.com",
|
|
169
|
+
port: 993,
|
|
170
|
+
user: "matthijs@example.com",
|
|
171
|
+
credentials: { kind: "accessToken", accessToken: "access-token" },
|
|
172
|
+
tls: true,
|
|
173
|
+
});
|
|
174
|
+
mock.method(
|
|
175
|
+
connection as unknown as { attemptConnect: () => Promise<void> },
|
|
176
|
+
"attemptConnect",
|
|
177
|
+
async () => {
|
|
178
|
+
throw Object.assign(new Error("Command failed"), {
|
|
179
|
+
authenticationFailed: true,
|
|
180
|
+
serverResponseCode: "AUTHENTICATIONFAILED",
|
|
181
|
+
responseText: REFUSAL,
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
await withOAuthLifecycle(deps, account, silentLogger, async () => {
|
|
187
|
+
await connection.connect();
|
|
145
188
|
});
|
|
189
|
+
|
|
190
|
+
assert.equal(recorded.stateUpdates.length, 1);
|
|
191
|
+
assert.equal(
|
|
192
|
+
recorded.stateUpdates[0].state,
|
|
193
|
+
ConnectionState.ReauthRequired,
|
|
194
|
+
);
|
|
195
|
+
assert.ok(
|
|
196
|
+
recorded.stateUpdates[0].lastError?.includes(REFUSAL),
|
|
197
|
+
`the refusal must survive to the account row, got: ${recorded.stateUpdates[0].lastError}`,
|
|
198
|
+
);
|
|
146
199
|
});
|
|
147
200
|
|
|
148
201
|
it("on MailConnectionError auth for password account: rethrows (batch item failure, no state flip)", async () => {
|
|
@@ -221,7 +274,11 @@ describe("withOAuthLifecycle", () => {
|
|
|
221
274
|
|
|
222
275
|
assert.equal(recorded.workCalls, 0, "no IMAP traffic without a credential");
|
|
223
276
|
assert.deepEqual(recorded.stateUpdates, [
|
|
224
|
-
{
|
|
277
|
+
{
|
|
278
|
+
accountId: "acc-1",
|
|
279
|
+
state: ConnectionState.CredentialsMissing,
|
|
280
|
+
lastError: undefined,
|
|
281
|
+
},
|
|
225
282
|
]);
|
|
226
283
|
});
|
|
227
284
|
|
|
@@ -236,7 +293,11 @@ describe("withOAuthLifecycle", () => {
|
|
|
236
293
|
|
|
237
294
|
assert.equal(recorded.workCalls, 0, "no IMAP traffic without a credential");
|
|
238
295
|
assert.deepEqual(recorded.stateUpdates, [
|
|
239
|
-
{
|
|
296
|
+
{
|
|
297
|
+
accountId: "acc-1",
|
|
298
|
+
state: ConnectionState.ReauthRequired,
|
|
299
|
+
lastError: undefined,
|
|
300
|
+
},
|
|
240
301
|
]);
|
|
241
302
|
});
|
|
242
303
|
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
* - On a terminal auth failure (RefreshTokenError reauth-required, or
|
|
17
17
|
* MailConnectionError auth), flip the account to reauth_required and return
|
|
18
18
|
* WITHOUT rethrowing — this ACKs the SQS message so it is not retried.
|
|
19
|
+
* An IMAP refusal is stored with it, because not every refusal a mail server
|
|
20
|
+
* hands back is one re-authenticating clears: "SmtpClientAuthentication is
|
|
21
|
+
* disabled" needs a tenant setting changed, and an account card that only
|
|
22
|
+
* ever says "Re-authentication required" sends the user around a loop.
|
|
19
23
|
* - On transient / config / network errors, rethrow so SQS retries with
|
|
20
24
|
* backoff (let-it-crash).
|
|
21
25
|
*
|
|
@@ -42,12 +46,17 @@ export type { ConnectionStateValue };
|
|
|
42
46
|
|
|
43
47
|
export interface OAuthLifecycleDeps extends AccountCredentialsDeps {
|
|
44
48
|
/**
|
|
45
|
-
* Persist the account's connectionState
|
|
46
|
-
*
|
|
49
|
+
* Persist the account's connectionState, and with it the sentence the
|
|
50
|
+
* account card shows for the failure. `lastError` carries the server's own
|
|
51
|
+
* words when the failure came with any; it is absent when nothing was said,
|
|
52
|
+
* as for a credential that was never stored or a token simply revoked, and
|
|
53
|
+
* the stored reason is then cleared rather than left to describe an older
|
|
54
|
+
* failure the account has moved on from.
|
|
47
55
|
*/
|
|
48
56
|
updateConnectionState: (
|
|
49
57
|
accountId: string,
|
|
50
58
|
state: ConnectionStateValue,
|
|
59
|
+
lastError?: string,
|
|
51
60
|
) => Promise<void>;
|
|
52
61
|
/**
|
|
53
62
|
* Resolve credentials for the account. Defaults to
|
|
@@ -131,12 +140,14 @@ export const withOAuthLifecycle = async (
|
|
|
131
140
|
{
|
|
132
141
|
accountId: account.accountId,
|
|
133
142
|
errorKind: err.kind,
|
|
143
|
+
error: err.message,
|
|
134
144
|
},
|
|
135
145
|
"IMAP auth rejected; marking account reauth_required",
|
|
136
146
|
);
|
|
137
147
|
await deps.updateConnectionState(
|
|
138
148
|
account.accountId,
|
|
139
149
|
ConnectionState.ReauthRequired,
|
|
150
|
+
err.message,
|
|
140
151
|
);
|
|
141
152
|
return; // ACK — do not retry
|
|
142
153
|
}
|