@remit/imap-worker 0.0.86 → 0.0.87
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 +3 -1
- package/package.json +10 -2
- package/src/account-check.ts +1 -1
- package/src/body-sync-gate.test.ts +1 -1
- package/src/body-sync-gate.ts +1 -1
- package/src/e2e-processor-shim.ts +6 -21
- package/src/emit.ts +1 -1
- package/src/failure-kind.ts +1 -1
- package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
- package/src/handlers/append-sent-message.ts +3 -24
- package/src/handlers/delete-account-objects.test.ts +1 -1
- package/src/handlers/delete-account-objects.ts +1 -1
- package/src/handlers/empty-trash.sqlite.test.ts +209 -0
- package/src/handlers/empty-trash.ts +3 -24
- package/src/handlers/flag-push.ts +11 -18
- package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
- package/src/handlers/mailbox-management.ts +12 -48
- package/src/handlers/message-copy.sqlite.test.ts +298 -0
- package/src/handlers/message-copy.ts +31 -49
- package/src/handlers/message-delete-terminal.ts +12 -10
- package/src/handlers/message-delete.sqlite.test.ts +291 -0
- package/src/handlers/message-delete.ts +26 -32
- package/src/handlers/message-move-terminal.ts +12 -10
- package/src/handlers/message-move.sqlite.test.ts +271 -0
- package/src/handlers/message-move.test.ts +76 -863
- package/src/handlers/message-move.ts +49 -39
- package/src/handlers/placement-move-push.test.ts +96 -329
- package/src/handlers/placement-move-push.ts +75 -29
- package/src/handlers/placement-push.sqlite.test.ts +298 -0
- package/src/handlers/probe-message-id.sqlite.test.ts +197 -0
- package/src/handlers/probe-message-id.ts +15 -0
- package/src/handlers/sync-mailboxes.test.ts +0 -1
- package/src/handlers/sync-mailboxes.ts +1 -1
- package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
- package/src/handlers/sync-message-body.test.ts +3 -299
- package/src/handlers/sync-message-body.ts +21 -11
- package/src/handlers/sync-messages.sqlite.test.ts +183 -0
- package/src/handlers/sync-messages.ts +27 -33
- package/src/index.ts +5 -3
- package/src/mail-sync-selection.sqlite.test.ts +16 -18
- package/src/poller.ts +5 -4
- package/src/processor.test.ts +1 -1
- package/src/processor.ts +1 -1
- package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
- package/src/scheduler/attachment-sweep.ts +1 -1
- package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
- package/src/scheduler/calendar-subscriptions.ts +6 -6
- package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
- package/src/scheduler/handler.ts +12 -1
- package/src/scheduler/loop.test.ts +2 -0
- package/src/scheduler/run-tick.sqlite.test.ts +192 -0
- package/src/scheduler/run-tick.ts +39 -1
- package/src/scheduler/runner.ts +21 -1
- package/src/scheduler/stranded-placement-redrive.ts +265 -0
- package/src/stranded-placement.sqlite.test.ts +795 -0
- package/src/test-helpers/test-world.ts +398 -0
- package/src/thread-join.sqlite.test.ts +115 -0
- package/src/with-oauth-lifecycle-deps.sqlite.test.ts +138 -14
- package/src/with-oauth-lifecycle.ts +1 -1
- package/src/handlers/append-sent-message.test.ts +0 -529
- package/src/handlers/empty-trash.test.ts +0 -728
- package/src/handlers/flag-push-placement-race.test.ts +0 -385
- package/src/handlers/flag-push.test.ts +0 -325
- package/src/handlers/mailbox-management.test.ts +0 -850
- package/src/handlers/message-copy.test.ts +0 -654
- package/src/handlers/message-delete.test.ts +0 -1869
- package/src/handlers/message-move-terminal.test.ts +0 -412
- package/src/handlers/sync-messages.test.ts +0 -569
- package/src/scheduler/attachment-sweep.test.ts +0 -169
- package/src/scheduler/run-tick.test.ts +0 -478
- package/src/with-oauth-lifecycle.test.ts +0 -318
|
@@ -1,318 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it, mock } 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 { ImapFlowConnection } from "@remit/mailbox-service/imapflow-connection";
|
|
7
|
-
import {
|
|
8
|
-
MailConnectionError,
|
|
9
|
-
type MailCredentials,
|
|
10
|
-
} from "@remit/mailbox-service/types";
|
|
11
|
-
import {
|
|
12
|
-
type ConnectionStateValue,
|
|
13
|
-
type OAuthLifecycleDeps,
|
|
14
|
-
withOAuthLifecycle,
|
|
15
|
-
} from "./with-oauth-lifecycle.js";
|
|
16
|
-
|
|
17
|
-
const silentLogger = {
|
|
18
|
-
info: () => {},
|
|
19
|
-
warn: () => {},
|
|
20
|
-
error: () => {},
|
|
21
|
-
debug: () => {},
|
|
22
|
-
trace: () => {},
|
|
23
|
-
fatal: () => {},
|
|
24
|
-
child: () => silentLogger,
|
|
25
|
-
} as never;
|
|
26
|
-
|
|
27
|
-
const buildAccount = (overrides: Partial<AccountItem> = {}): AccountItem =>
|
|
28
|
-
({
|
|
29
|
-
accountId: "acc-1",
|
|
30
|
-
accountConfigId: "cfg-1",
|
|
31
|
-
username: "alice@example.com",
|
|
32
|
-
email: "alice@example.com",
|
|
33
|
-
imapHost: "imap.example.com",
|
|
34
|
-
imapPort: 993,
|
|
35
|
-
imapTls: true,
|
|
36
|
-
imapStartTls: false,
|
|
37
|
-
isActive: true,
|
|
38
|
-
connectionState: "not_authenticated",
|
|
39
|
-
createdAt: 0,
|
|
40
|
-
updatedAt: 0,
|
|
41
|
-
...overrides,
|
|
42
|
-
}) as unknown as AccountItem;
|
|
43
|
-
|
|
44
|
-
interface Recorded {
|
|
45
|
-
stateUpdates: Array<{
|
|
46
|
-
accountId: string;
|
|
47
|
-
state: ConnectionStateValue;
|
|
48
|
-
lastError?: string;
|
|
49
|
-
}>;
|
|
50
|
-
resolveCalls: number;
|
|
51
|
-
workCalls: number;
|
|
52
|
-
}
|
|
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
|
-
|
|
58
|
-
const passwordCreds: MailCredentials = {
|
|
59
|
-
kind: "password",
|
|
60
|
-
password: "secret",
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const buildDeps = (
|
|
64
|
-
options: {
|
|
65
|
-
resolveCredentials?: OAuthLifecycleDeps["resolveCredentials"];
|
|
66
|
-
} = {},
|
|
67
|
-
): { deps: OAuthLifecycleDeps; recorded: Recorded } => {
|
|
68
|
-
const recorded: Recorded = {
|
|
69
|
-
stateUpdates: [],
|
|
70
|
-
resolveCalls: 0,
|
|
71
|
-
workCalls: 0,
|
|
72
|
-
};
|
|
73
|
-
const deps: OAuthLifecycleDeps = {
|
|
74
|
-
secrets: {
|
|
75
|
-
decrypt: async () => "",
|
|
76
|
-
encrypt: async () => ({}) as never,
|
|
77
|
-
},
|
|
78
|
-
tokenService: { getAccessToken: async () => ({}) as never },
|
|
79
|
-
persistRotatedToken: async () => {},
|
|
80
|
-
updateConnectionState: async (accountId, state, lastError) => {
|
|
81
|
-
recorded.stateUpdates.push({ accountId, state, lastError });
|
|
82
|
-
},
|
|
83
|
-
resolveCredentials:
|
|
84
|
-
options.resolveCredentials ??
|
|
85
|
-
(async () => {
|
|
86
|
-
recorded.resolveCalls += 1;
|
|
87
|
-
return { status: "resolved", credentials: passwordCreds };
|
|
88
|
-
}),
|
|
89
|
-
};
|
|
90
|
-
// Wrap resolveCredentials to record the call count when a custom one is given.
|
|
91
|
-
if (options.resolveCredentials) {
|
|
92
|
-
const inner = options.resolveCredentials;
|
|
93
|
-
deps.resolveCredentials = async (account, credDeps) => {
|
|
94
|
-
recorded.resolveCalls += 1;
|
|
95
|
-
return inner(account, credDeps);
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
return { deps, recorded };
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
describe("withOAuthLifecycle", () => {
|
|
102
|
-
it("skips work when account is reauth_required", async () => {
|
|
103
|
-
const { deps, recorded } = buildDeps();
|
|
104
|
-
const account = buildAccount({ connectionState: "reauth_required" });
|
|
105
|
-
|
|
106
|
-
await withOAuthLifecycle(deps, account, silentLogger, async () => {
|
|
107
|
-
recorded.workCalls += 1;
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
assert.equal(recorded.workCalls, 0, "work must not be called");
|
|
111
|
-
assert.equal(recorded.resolveCalls, 0, "must not resolve credentials");
|
|
112
|
-
assert.equal(recorded.stateUpdates.length, 0, "must not update state");
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("on RefreshTokenError reauth-required: flips to reauth_required and ACKs (does not rethrow)", async () => {
|
|
116
|
-
const { deps, recorded } = buildDeps({
|
|
117
|
-
resolveCredentials: async () => {
|
|
118
|
-
throw new RefreshTokenError({
|
|
119
|
-
kind: "reauth-required",
|
|
120
|
-
code: "invalid_grant",
|
|
121
|
-
});
|
|
122
|
-
},
|
|
123
|
-
});
|
|
124
|
-
const account = buildAccount();
|
|
125
|
-
|
|
126
|
-
await withOAuthLifecycle(deps, account, silentLogger, async () => {
|
|
127
|
-
recorded.workCalls += 1;
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
assert.equal(
|
|
131
|
-
recorded.workCalls,
|
|
132
|
-
0,
|
|
133
|
-
"work must not run after resolve fails",
|
|
134
|
-
);
|
|
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.
|
|
138
|
-
assert.deepEqual(recorded.stateUpdates[0], {
|
|
139
|
-
accountId: "acc-1",
|
|
140
|
-
state: ConnectionState.ReauthRequired,
|
|
141
|
-
lastError: undefined,
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("on MailConnectionError auth for OAuth account: flips to reauth_required and ACKs", async () => {
|
|
146
|
-
const { deps, recorded } = buildDeps();
|
|
147
|
-
const account = buildAccount({ authType: AccountAuthType.OauthMicrosoft });
|
|
148
|
-
|
|
149
|
-
await withOAuthLifecycle(deps, account, silentLogger, async () => {
|
|
150
|
-
throw new MailConnectionError("auth", "auth failed");
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
assert.equal(recorded.stateUpdates.length, 1);
|
|
154
|
-
assert.deepEqual(recorded.stateUpdates[0], {
|
|
155
|
-
accountId: "acc-1",
|
|
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();
|
|
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
|
-
);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it("on MailConnectionError auth for password account: rethrows (batch item failure, no state flip)", async () => {
|
|
202
|
-
const { deps, recorded } = buildDeps();
|
|
203
|
-
const account = buildAccount({ authType: AccountAuthType.Password });
|
|
204
|
-
|
|
205
|
-
await assert.rejects(
|
|
206
|
-
() =>
|
|
207
|
-
withOAuthLifecycle(deps, account, silentLogger, async () => {
|
|
208
|
-
throw new MailConnectionError("auth", "auth failed");
|
|
209
|
-
}),
|
|
210
|
-
/auth failed/,
|
|
211
|
-
);
|
|
212
|
-
assert.equal(
|
|
213
|
-
recorded.stateUpdates.length,
|
|
214
|
-
0,
|
|
215
|
-
"must not flip connectionState for password account",
|
|
216
|
-
);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it("on MailConnectionError auth for account with no authType (defaults to password): rethrows", async () => {
|
|
220
|
-
const { deps, recorded } = buildDeps();
|
|
221
|
-
// No authType set — defaults to password in resolveConnectionCredentials
|
|
222
|
-
const account = buildAccount();
|
|
223
|
-
|
|
224
|
-
await assert.rejects(
|
|
225
|
-
() =>
|
|
226
|
-
withOAuthLifecycle(deps, account, silentLogger, async () => {
|
|
227
|
-
throw new MailConnectionError("auth", "auth failed");
|
|
228
|
-
}),
|
|
229
|
-
/auth failed/,
|
|
230
|
-
);
|
|
231
|
-
assert.equal(
|
|
232
|
-
recorded.stateUpdates.length,
|
|
233
|
-
0,
|
|
234
|
-
"must not flip connectionState when authType is unset",
|
|
235
|
-
);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("on transient error (network): rethrows (batch item failure)", async () => {
|
|
239
|
-
const { deps, recorded } = buildDeps();
|
|
240
|
-
const account = buildAccount();
|
|
241
|
-
|
|
242
|
-
await assert.rejects(
|
|
243
|
-
() =>
|
|
244
|
-
withOAuthLifecycle(deps, account, silentLogger, async () => {
|
|
245
|
-
throw new MailConnectionError("network", "timeout");
|
|
246
|
-
}),
|
|
247
|
-
/timeout/,
|
|
248
|
-
);
|
|
249
|
-
assert.equal(recorded.stateUpdates.length, 0, "must not flip state");
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
it("on ordinary Error: rethrows", async () => {
|
|
253
|
-
const { deps, recorded } = buildDeps();
|
|
254
|
-
const account = buildAccount();
|
|
255
|
-
|
|
256
|
-
await assert.rejects(
|
|
257
|
-
() =>
|
|
258
|
-
withOAuthLifecycle(deps, account, silentLogger, async () => {
|
|
259
|
-
throw new Error("boom");
|
|
260
|
-
}),
|
|
261
|
-
/boom/,
|
|
262
|
-
);
|
|
263
|
-
assert.equal(recorded.stateUpdates.length, 0);
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it("password account with no passwordHash: ACKs and flips to credentials_missing", async () => {
|
|
267
|
-
const { deps, recorded } = buildDeps();
|
|
268
|
-
delete deps.resolveCredentials;
|
|
269
|
-
const account = buildAccount({ authType: AccountAuthType.Password });
|
|
270
|
-
|
|
271
|
-
await withOAuthLifecycle(deps, account, silentLogger, async () => {
|
|
272
|
-
recorded.workCalls += 1;
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
assert.equal(recorded.workCalls, 0, "no IMAP traffic without a credential");
|
|
276
|
-
assert.deepEqual(recorded.stateUpdates, [
|
|
277
|
-
{
|
|
278
|
-
accountId: "acc-1",
|
|
279
|
-
state: ConnectionState.CredentialsMissing,
|
|
280
|
-
lastError: undefined,
|
|
281
|
-
},
|
|
282
|
-
]);
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
it("OAuth account with no refresh token: ACKs and flips to reauth_required", async () => {
|
|
286
|
-
const { deps, recorded } = buildDeps();
|
|
287
|
-
delete deps.resolveCredentials;
|
|
288
|
-
const account = buildAccount({ authType: AccountAuthType.OauthMicrosoft });
|
|
289
|
-
|
|
290
|
-
await withOAuthLifecycle(deps, account, silentLogger, async () => {
|
|
291
|
-
recorded.workCalls += 1;
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
assert.equal(recorded.workCalls, 0, "no IMAP traffic without a credential");
|
|
295
|
-
assert.deepEqual(recorded.stateUpdates, [
|
|
296
|
-
{
|
|
297
|
-
accountId: "acc-1",
|
|
298
|
-
state: ConnectionState.ReauthRequired,
|
|
299
|
-
lastError: undefined,
|
|
300
|
-
},
|
|
301
|
-
]);
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it("on RefreshTokenError transient: rethrows", async () => {
|
|
305
|
-
const { deps, recorded } = buildDeps({
|
|
306
|
-
resolveCredentials: async () => {
|
|
307
|
-
throw new RefreshTokenError({ kind: "transient", code: "503" });
|
|
308
|
-
},
|
|
309
|
-
});
|
|
310
|
-
const account = buildAccount();
|
|
311
|
-
|
|
312
|
-
await assert.rejects(
|
|
313
|
-
() => withOAuthLifecycle(deps, account, silentLogger, async () => {}),
|
|
314
|
-
/transient/,
|
|
315
|
-
);
|
|
316
|
-
assert.equal(recorded.stateUpdates.length, 0, "must not flip state");
|
|
317
|
-
});
|
|
318
|
-
});
|