@remit/imap-worker 0.0.63 → 0.0.64
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.64",
|
|
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
|
+
});
|
|
@@ -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
|
-
|
|
29
|
-
} from "@remit/mailbox-service";
|
|
38
|
+
} from "@remit/mailbox-service/types";
|
|
30
39
|
import { isAccountReauthRequired } from "./account-check.js";
|
|
31
40
|
|
|
32
|
-
|
|
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<
|
|
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
|
|
77
|
-
|
|
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) {
|