@remit/imap-worker 0.0.86 → 0.0.88
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 +194 -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 +21 -21
- 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 +199 -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 +106 -0
- package/src/with-oauth-lifecycle-deps.sqlite.test.ts +146 -15
- 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
package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts}
RENAMED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import type { CalendarFeedFetcher } from "@remit/calendar-service";
|
|
4
|
-
import { MemoryCalendarStore } from "@remit/calendar-service/memory-store";
|
|
5
4
|
import type { CalendarCollectionItem } from "@remit/data-ports";
|
|
6
5
|
import { CalendarSource } from "@remit/domain-enums";
|
|
7
|
-
import type { Logger } from "@remit/logger
|
|
6
|
+
import type { Logger } from "@remit/logger";
|
|
7
|
+
import { openStore } from "../test-helpers/test-world.js";
|
|
8
8
|
import { refreshDueCalendarSubscriptions } from "./calendar-subscriptions.js";
|
|
9
9
|
|
|
10
10
|
const INTERVAL_MS = 15 * 60 * 1000;
|
|
@@ -41,27 +41,25 @@ const logged = (): { log: Logger; lines: string[] } => {
|
|
|
41
41
|
return { log, lines };
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
type Client = Awaited<ReturnType<typeof openStore>>["client"];
|
|
45
|
+
|
|
44
46
|
const subscription = async (
|
|
45
|
-
|
|
47
|
+
client: Client,
|
|
46
48
|
urlSegment: string,
|
|
47
49
|
checkedAt: number,
|
|
48
50
|
): Promise<CalendarCollectionItem> => {
|
|
49
|
-
const created = await
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
created.accountConfigId,
|
|
62
|
-
created.calendarId,
|
|
63
|
-
{ subscriptionCheckedAt: checkedAt },
|
|
64
|
-
),
|
|
51
|
+
const created = await client.calendarCollection.create({
|
|
52
|
+
accountConfigId: "account-config-1",
|
|
53
|
+
urlSegment,
|
|
54
|
+
displayName: urlSegment,
|
|
55
|
+
source: CalendarSource.Subscribed,
|
|
56
|
+
subscriptionUrl: `https://calendar.example/${urlSegment}/private-feedfacecafe.ics`,
|
|
57
|
+
subscriptionEnabled: true,
|
|
58
|
+
});
|
|
59
|
+
return client.calendarCollection.update(
|
|
60
|
+
created.accountConfigId,
|
|
61
|
+
created.calendarId,
|
|
62
|
+
{ subscriptionCheckedAt: checkedAt },
|
|
65
63
|
);
|
|
66
64
|
};
|
|
67
65
|
|
|
@@ -73,29 +71,23 @@ const recording =
|
|
|
73
71
|
};
|
|
74
72
|
|
|
75
73
|
describe("refreshDueCalendarSubscriptions", () => {
|
|
76
|
-
it("fetches the subscriptions that are due and skips the rest", async () => {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
await subscription(
|
|
80
|
-
|
|
81
|
-
await
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
),
|
|
74
|
+
it("fetches the subscriptions that are due and skips the rest", async (t) => {
|
|
75
|
+
const { client, close } = await openStore();
|
|
76
|
+
t.after(close);
|
|
77
|
+
const due = await subscription(client, "due", NOW - INTERVAL_MS);
|
|
78
|
+
await subscription(client, "fresh", NOW - 60_000);
|
|
79
|
+
const paused = await subscription(client, "paused", 0);
|
|
80
|
+
await client.calendarCollection.update(
|
|
81
|
+
paused.accountConfigId,
|
|
82
|
+
paused.calendarId,
|
|
83
|
+
{ subscriptionEnabled: false },
|
|
87
84
|
);
|
|
88
85
|
const asked: string[] = [];
|
|
89
86
|
|
|
90
87
|
const tally = await refreshDueCalendarSubscriptions(
|
|
91
88
|
{
|
|
92
|
-
calendarCollection:
|
|
93
|
-
|
|
94
|
-
store.transaction((repos) =>
|
|
95
|
-
repos.calendarCollection.listEnabledSubscriptions(),
|
|
96
|
-
),
|
|
97
|
-
},
|
|
98
|
-
calendarUnitOfWork: store,
|
|
89
|
+
calendarCollection: client.calendarCollection,
|
|
90
|
+
calendarStore: client.calendarStore,
|
|
99
91
|
log: logged().log,
|
|
100
92
|
fetcher: recording(asked, () => new Response(FEED, { status: 200 })),
|
|
101
93
|
},
|
|
@@ -105,30 +97,24 @@ describe("refreshDueCalendarSubscriptions", () => {
|
|
|
105
97
|
|
|
106
98
|
assert.deepEqual(asked, [due.subscriptionUrl]);
|
|
107
99
|
assert.deepEqual(tally, { refreshed: 1, failed: 0, notDue: 1 });
|
|
108
|
-
const objects = await
|
|
109
|
-
repos.calendarObject.listByCalendar(due.calendarId),
|
|
110
|
-
);
|
|
100
|
+
const objects = await client.calendarObject.listByCalendar(due.calendarId);
|
|
111
101
|
assert.deepEqual(
|
|
112
102
|
objects.map((object) => object.summary),
|
|
113
103
|
["Ferry crossing"],
|
|
114
104
|
);
|
|
115
105
|
});
|
|
116
106
|
|
|
117
|
-
it("counts a feed that fails, keeps refreshing the others, and never logs an address", async () => {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
const
|
|
107
|
+
it("counts a feed that fails, keeps refreshing the others, and never logs an address", async (t) => {
|
|
108
|
+
const { client, close } = await openStore();
|
|
109
|
+
t.after(close);
|
|
110
|
+
const broken = await subscription(client, "broken", 0);
|
|
111
|
+
const working = await subscription(client, "working", 0);
|
|
121
112
|
const { log, lines } = logged();
|
|
122
113
|
|
|
123
114
|
const tally = await refreshDueCalendarSubscriptions(
|
|
124
115
|
{
|
|
125
|
-
calendarCollection:
|
|
126
|
-
|
|
127
|
-
store.transaction((repos) =>
|
|
128
|
-
repos.calendarCollection.listEnabledSubscriptions(),
|
|
129
|
-
),
|
|
130
|
-
},
|
|
131
|
-
calendarUnitOfWork: store,
|
|
116
|
+
calendarCollection: client.calendarCollection,
|
|
117
|
+
calendarStore: client.calendarStore,
|
|
132
118
|
log,
|
|
133
119
|
fetcher: recording([], (url) =>
|
|
134
120
|
url === broken.subscriptionUrl
|
|
@@ -141,13 +127,15 @@ describe("refreshDueCalendarSubscriptions", () => {
|
|
|
141
127
|
);
|
|
142
128
|
|
|
143
129
|
assert.deepEqual(tally, { refreshed: 1, failed: 1, notDue: 0 });
|
|
144
|
-
const stored = await
|
|
145
|
-
|
|
130
|
+
const stored = await client.calendarCollection.get(
|
|
131
|
+
broken.accountConfigId,
|
|
132
|
+
broken.calendarId,
|
|
146
133
|
);
|
|
147
134
|
assert.equal(stored.subscriptionError, "the feed answered HTTP 410");
|
|
148
135
|
assert.equal(stored.subscriptionCheckedAt, NOW);
|
|
149
|
-
const refreshed = await
|
|
150
|
-
|
|
136
|
+
const refreshed = await client.calendarCollection.get(
|
|
137
|
+
working.accountConfigId,
|
|
138
|
+
working.calendarId,
|
|
151
139
|
);
|
|
152
140
|
assert.equal(refreshed.subscriptionFetchedAt, NOW);
|
|
153
141
|
assert.ok(lines.some((line) => line.includes(broken.calendarId)));
|
|
@@ -6,9 +6,9 @@ import {
|
|
|
6
6
|
} from "@remit/calendar-service";
|
|
7
7
|
import type {
|
|
8
8
|
ICalendarCollectionRepository,
|
|
9
|
-
|
|
9
|
+
ICalendarStore,
|
|
10
10
|
} from "@remit/data-ports";
|
|
11
|
-
import type { Logger } from "@remit/logger
|
|
11
|
+
import type { Logger } from "@remit/logger";
|
|
12
12
|
import pMap from "p-map";
|
|
13
13
|
|
|
14
14
|
const REFRESH_CONCURRENCY = 4;
|
|
@@ -24,7 +24,7 @@ export interface CalendarSubscriptionRefreshDeps {
|
|
|
24
24
|
ICalendarCollectionRepository,
|
|
25
25
|
"listEnabledSubscriptions"
|
|
26
26
|
>;
|
|
27
|
-
|
|
27
|
+
calendarStore: ICalendarStore;
|
|
28
28
|
log: Logger;
|
|
29
29
|
fetcher?: CalendarFeedFetcher;
|
|
30
30
|
}
|
|
@@ -52,7 +52,7 @@ export const refreshDueCalendarSubscriptions = async (
|
|
|
52
52
|
due,
|
|
53
53
|
(collection) =>
|
|
54
54
|
refreshCalendarSubscription(
|
|
55
|
-
deps.
|
|
55
|
+
deps.calendarStore,
|
|
56
56
|
collection,
|
|
57
57
|
now,
|
|
58
58
|
deps.fetcher,
|
|
@@ -85,7 +85,7 @@ export const refreshDueCalendarSubscriptions = async (
|
|
|
85
85
|
|
|
86
86
|
export const buildCalendarSubscriptionRefresh =
|
|
87
87
|
(
|
|
88
|
-
client: Pick<RemitClient, "calendarCollection" | "
|
|
88
|
+
client: Pick<RemitClient, "calendarCollection" | "calendarStore">,
|
|
89
89
|
log: Logger,
|
|
90
90
|
): ((
|
|
91
91
|
now: number,
|
|
@@ -95,7 +95,7 @@ export const buildCalendarSubscriptionRefresh =
|
|
|
95
95
|
refreshDueCalendarSubscriptions(
|
|
96
96
|
{
|
|
97
97
|
calendarCollection: client.calendarCollection,
|
|
98
|
-
|
|
98
|
+
calendarStore: client.calendarStore,
|
|
99
99
|
log,
|
|
100
100
|
},
|
|
101
101
|
now,
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { after, before, beforeEach, describe, it } from "node:test";
|
|
4
|
+
import type { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
|
|
5
|
+
import { finishHalfDeletedAccounts } from "@remit/backend/account-deletion";
|
|
6
|
+
import type { RemitClient } from "@remit/backend/client";
|
|
7
|
+
import { importConfig } from "@remit/config-transfer";
|
|
8
|
+
import type { AccountItem } from "@remit/data-ports";
|
|
9
|
+
import { AccountAuthType, ConnectionState } from "@remit/domain-enums";
|
|
10
|
+
import { noopLogger } from "@remit/logger";
|
|
11
|
+
import { openStore } from "../test-helpers/test-world.js";
|
|
12
|
+
|
|
13
|
+
const FANOUT_URL = "http://queue:9324/000000000000/remit-account-fanout";
|
|
14
|
+
|
|
15
|
+
let store: Awaited<ReturnType<typeof openStore>>;
|
|
16
|
+
let client: RemitClient;
|
|
17
|
+
|
|
18
|
+
before(async () => {
|
|
19
|
+
store = await openStore();
|
|
20
|
+
client = store.client;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
after(() => store.close());
|
|
24
|
+
|
|
25
|
+
const storedId = (): string => randomBytes(13).toString("hex").slice(0, 25);
|
|
26
|
+
|
|
27
|
+
const configDocument = (accountId: string, email: string) => ({
|
|
28
|
+
kind: "reader.config",
|
|
29
|
+
schemaVersion: 1,
|
|
30
|
+
generator: {
|
|
31
|
+
app: "reader",
|
|
32
|
+
version: "v0.1.0",
|
|
33
|
+
exportedAt: "2026-08-27T09:15:00+02:00",
|
|
34
|
+
},
|
|
35
|
+
provenance: { accountConfigId: storedId(), instance: "reader.example.com" },
|
|
36
|
+
accountConfig: { name: "Imported" },
|
|
37
|
+
accounts: [
|
|
38
|
+
{
|
|
39
|
+
accountId,
|
|
40
|
+
email,
|
|
41
|
+
username: email,
|
|
42
|
+
authType: "password",
|
|
43
|
+
credentials: { required: "password" },
|
|
44
|
+
isActive: true,
|
|
45
|
+
imap: { host: "imap.example.com", port: 993, tls: true, startTls: false },
|
|
46
|
+
smtp: {
|
|
47
|
+
enabled: false,
|
|
48
|
+
host: "",
|
|
49
|
+
port: 587,
|
|
50
|
+
tls: false,
|
|
51
|
+
startTls: false,
|
|
52
|
+
username: "",
|
|
53
|
+
},
|
|
54
|
+
displayName: "",
|
|
55
|
+
muted: null,
|
|
56
|
+
composeLanguages: [],
|
|
57
|
+
signature: { plainText: "", html: "" },
|
|
58
|
+
folderRoles: [],
|
|
59
|
+
folderOverrides: [],
|
|
60
|
+
pinnedFolders: [],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
labels: [],
|
|
64
|
+
filters: [],
|
|
65
|
+
addressFlags: [],
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const seedAccount = (
|
|
69
|
+
accountConfigId: string,
|
|
70
|
+
name: string,
|
|
71
|
+
extra: { deletedAt?: number } = {},
|
|
72
|
+
): Promise<AccountItem> =>
|
|
73
|
+
client.account.create({
|
|
74
|
+
accountConfigId,
|
|
75
|
+
username: `${name}@example.com`,
|
|
76
|
+
email: `${name}@example.com`,
|
|
77
|
+
authType: AccountAuthType.Password,
|
|
78
|
+
passwordHash: '{"ciphertext":"x"}',
|
|
79
|
+
imapHost: "imap.example.com",
|
|
80
|
+
imapPort: 993,
|
|
81
|
+
imapTls: true,
|
|
82
|
+
imapStartTls: false,
|
|
83
|
+
isActive: true,
|
|
84
|
+
connectionState: ConnectionState.Authenticated,
|
|
85
|
+
...extra,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const importAccount = async (email: string): Promise<AccountItem> => {
|
|
89
|
+
const accountConfigId = storedId();
|
|
90
|
+
const accountId = storedId();
|
|
91
|
+
const outcome = await importConfig(
|
|
92
|
+
{
|
|
93
|
+
repositories: client,
|
|
94
|
+
appointFolderRole: async () => {},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
accountConfigId,
|
|
98
|
+
userId: accountConfigId,
|
|
99
|
+
document: configDocument(accountId, email),
|
|
100
|
+
mode: "apply",
|
|
101
|
+
onExisting: "abort",
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
assert.equal(outcome.outcome, "report");
|
|
105
|
+
return client.account.get(accountId);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const recordingSqs = (sent: SendMessageCommand[], fail = false): SQSClient =>
|
|
109
|
+
({
|
|
110
|
+
send: async (command: SendMessageCommand) => {
|
|
111
|
+
if (fail) throw new Error("queue unreachable");
|
|
112
|
+
sent.push(command);
|
|
113
|
+
return {};
|
|
114
|
+
},
|
|
115
|
+
}) as unknown as SQSClient;
|
|
116
|
+
|
|
117
|
+
const heal = (sqsClient: SQSClient) =>
|
|
118
|
+
finishHalfDeletedAccounts({
|
|
119
|
+
account: client.account,
|
|
120
|
+
configImport: client.configImport,
|
|
121
|
+
sqsClient,
|
|
122
|
+
accountFanoutQueueUrl: FANOUT_URL,
|
|
123
|
+
log: noopLogger,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("finishing an account deleted before #1384 on scheduler start", () => {
|
|
127
|
+
let halfDeleted: AccountItem;
|
|
128
|
+
let live: AccountItem;
|
|
129
|
+
let deleted: AccountItem;
|
|
130
|
+
let imported: AccountItem;
|
|
131
|
+
|
|
132
|
+
beforeEach(async () => {
|
|
133
|
+
const accountConfigId = storedId();
|
|
134
|
+
live = await seedAccount(accountConfigId, `live-${storedId()}`);
|
|
135
|
+
halfDeleted = await seedAccount(accountConfigId, `gone-${storedId()}`);
|
|
136
|
+
await client.account.update(halfDeleted.accountId, { isActive: false });
|
|
137
|
+
deleted = await seedAccount(accountConfigId, `deleted-${storedId()}`, {
|
|
138
|
+
deletedAt: 1_700_000_000_000,
|
|
139
|
+
});
|
|
140
|
+
await client.account.update(deleted.accountId, { isActive: false });
|
|
141
|
+
imported = await importAccount(`imported-${storedId()}@example.com`);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const allAccounts = () =>
|
|
145
|
+
client.account.get([
|
|
146
|
+
halfDeleted.accountId,
|
|
147
|
+
live.accountId,
|
|
148
|
+
deleted.accountId,
|
|
149
|
+
imported.accountId,
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
it("tombstones it and asks for the same purge a delete asks for", async () => {
|
|
153
|
+
const sent: SendMessageCommand[] = [];
|
|
154
|
+
|
|
155
|
+
await heal(recordingSqs(sent));
|
|
156
|
+
|
|
157
|
+
const purges = sent
|
|
158
|
+
.filter((command) => command.input.QueueUrl === FANOUT_URL)
|
|
159
|
+
.map((command) => JSON.parse(command.input.MessageBody ?? "{}"));
|
|
160
|
+
assert.ok(
|
|
161
|
+
purges.some(
|
|
162
|
+
(body) =>
|
|
163
|
+
body.type === "AccountDataPurge" &&
|
|
164
|
+
body.accountId === halfDeleted.accountId &&
|
|
165
|
+
body.accountConfigId === halfDeleted.accountConfigId,
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
const byId = new Map(
|
|
169
|
+
(await allAccounts()).map((account) => [account.accountId, account]),
|
|
170
|
+
);
|
|
171
|
+
assert.ok(byId.get(halfDeleted.accountId)?.deletedAt);
|
|
172
|
+
assert.equal(byId.get(live.accountId)?.deletedAt, undefined);
|
|
173
|
+
assert.equal(byId.get(imported.accountId)?.deletedAt, undefined);
|
|
174
|
+
assert.equal(byId.get(deleted.accountId)?.deletedAt, 1_700_000_000_000);
|
|
175
|
+
assert.ok(
|
|
176
|
+
!purges.some((body) =>
|
|
177
|
+
[live.accountId, imported.accountId, deleted.accountId].includes(
|
|
178
|
+
body.accountId,
|
|
179
|
+
),
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("does nothing for it on the next start", async () => {
|
|
185
|
+
await heal(recordingSqs([]));
|
|
186
|
+
const sent: SendMessageCommand[] = [];
|
|
187
|
+
|
|
188
|
+
await heal(recordingSqs(sent));
|
|
189
|
+
|
|
190
|
+
assert.ok(
|
|
191
|
+
!sent.some((command) =>
|
|
192
|
+
(command.input.MessageBody ?? "").includes(halfDeleted.accountId),
|
|
193
|
+
),
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("fails the start and leaves it to be found again when the purge cannot be asked for", async () => {
|
|
198
|
+
await assert.rejects(heal(recordingSqs([], true)), /queue unreachable/);
|
|
199
|
+
|
|
200
|
+
const after = await client.account.get(halfDeleted.accountId);
|
|
201
|
+
assert.equal(after.deletedAt, undefined);
|
|
202
|
+
});
|
|
203
|
+
});
|
package/src/scheduler/handler.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
|
-
import { createLogger
|
|
2
|
+
import { createLogger } from "@remit/logger";
|
|
3
|
+
import { installNodeLogger, withTelemetry } from "@remit/logger-lambda/node";
|
|
3
4
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
4
5
|
import type { ScheduledHandler } from "aws-lambda";
|
|
5
6
|
import { env } from "expect-env";
|
|
7
|
+
import { emitEvent } from "../emit.js";
|
|
6
8
|
import { buildAttachmentSweep } from "./attachment-sweep.js";
|
|
7
9
|
import { buildCalendarSubscriptionRefresh } from "./calendar-subscriptions.js";
|
|
8
10
|
import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
|
|
9
11
|
import { runSchedulerTick } from "./run-tick.js";
|
|
12
|
+
import { redriveStrandedPlacements } from "./stranded-placement-redrive.js";
|
|
13
|
+
|
|
14
|
+
installNodeLogger();
|
|
10
15
|
|
|
11
16
|
const log = createLogger();
|
|
12
17
|
|
|
@@ -47,5 +52,11 @@ export const handler: ScheduledHandler = withTelemetry(async (event) => {
|
|
|
47
52
|
// already gone and this sweep is the only thing that collects it.
|
|
48
53
|
sweepAttachments: buildAttachmentSweep(client, log),
|
|
49
54
|
refreshCalendarSubscriptions: buildCalendarSubscriptionRefresh(client, log),
|
|
55
|
+
redriveStrandedPlacements: (account, tickNow) =>
|
|
56
|
+
redriveStrandedPlacements(
|
|
57
|
+
{ client, emit: emitEvent, log },
|
|
58
|
+
account,
|
|
59
|
+
tickNow,
|
|
60
|
+
),
|
|
50
61
|
});
|
|
51
62
|
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import type { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
|
|
4
|
+
import type { AccountItem } from "@remit/data-ports";
|
|
5
|
+
import {
|
|
6
|
+
AccountAuthType,
|
|
7
|
+
AccountService,
|
|
8
|
+
ConnectionState,
|
|
9
|
+
} from "@remit/domain-enums";
|
|
10
|
+
import {
|
|
11
|
+
type AccountRepo,
|
|
12
|
+
createSqliteRepositories,
|
|
13
|
+
} from "@remit/drizzle-service";
|
|
14
|
+
import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
|
|
15
|
+
import { capturingLogger, type LogLine } from "../test-helpers/test-world.js";
|
|
16
|
+
import { runSchedulerTick } from "./run-tick.js";
|
|
17
|
+
|
|
18
|
+
const TICK_INTERVAL_MS = 60 * 60 * 1000;
|
|
19
|
+
const OFFLINE_INTERVAL_MS = 12 * 60 * 60 * 1000;
|
|
20
|
+
const NOW = 1_700_000_000_000;
|
|
21
|
+
|
|
22
|
+
let accounts: AccountRepo;
|
|
23
|
+
let close: () => void;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
const store = createShippedSqliteDb();
|
|
27
|
+
close = store.close;
|
|
28
|
+
accounts = createSqliteRepositories({
|
|
29
|
+
app: store.db,
|
|
30
|
+
mail: store.db,
|
|
31
|
+
search: store.db,
|
|
32
|
+
}).account;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => close());
|
|
36
|
+
|
|
37
|
+
const account = async (
|
|
38
|
+
name: string,
|
|
39
|
+
overrides: Partial<AccountItem> = {},
|
|
40
|
+
stored: { credential: boolean } = { credential: true },
|
|
41
|
+
): Promise<AccountItem> => {
|
|
42
|
+
const created = await accounts.create({
|
|
43
|
+
accountConfigId: `cfg-${name}`,
|
|
44
|
+
username: `${name}@example.com`,
|
|
45
|
+
email: `${name}@example.com`,
|
|
46
|
+
authType: AccountAuthType.Password,
|
|
47
|
+
passwordHash: stored.credential ? '{"ciphertext":"x"}' : undefined,
|
|
48
|
+
imapHost: "imap.example.com",
|
|
49
|
+
imapPort: 993,
|
|
50
|
+
imapTls: true,
|
|
51
|
+
imapStartTls: false,
|
|
52
|
+
isActive: true,
|
|
53
|
+
connectionState: ConnectionState.Authenticated,
|
|
54
|
+
} as Parameters<AccountRepo["create"]>[0]);
|
|
55
|
+
if (Object.keys(overrides).length === 0) return created;
|
|
56
|
+
await accounts.update(created.accountId, overrides);
|
|
57
|
+
return accounts.get(created.accountId);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const tick = async (
|
|
61
|
+
options: Partial<Parameters<typeof runSchedulerTick>[0]> = {},
|
|
62
|
+
) => {
|
|
63
|
+
const sent: SendMessageCommand[] = [];
|
|
64
|
+
const logs: LogLine[] = [];
|
|
65
|
+
const result = await runSchedulerTick({
|
|
66
|
+
accountService: accounts,
|
|
67
|
+
sqsClient: {
|
|
68
|
+
send: async (command: SendMessageCommand) => {
|
|
69
|
+
sent.push(command);
|
|
70
|
+
return {};
|
|
71
|
+
},
|
|
72
|
+
} as unknown as SQSClient,
|
|
73
|
+
queueUrl:
|
|
74
|
+
"https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
|
|
75
|
+
log: capturingLogger(logs),
|
|
76
|
+
tickIntervalMs: TICK_INTERVAL_MS,
|
|
77
|
+
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
78
|
+
now: NOW,
|
|
79
|
+
redriveStrandedPlacements: async () => 0,
|
|
80
|
+
...options,
|
|
81
|
+
});
|
|
82
|
+
const enqueued = sent.map(
|
|
83
|
+
(command) => JSON.parse(command.input.MessageBody ?? "{}").accountId,
|
|
84
|
+
);
|
|
85
|
+
return { result, sent, enqueued, logs };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
describe("a scheduler tick", () => {
|
|
89
|
+
it("enqueues the accounts that are due and only those", async () => {
|
|
90
|
+
const due = await account("due", {
|
|
91
|
+
lastSyncAt: NOW - OFFLINE_INTERVAL_MS - 1,
|
|
92
|
+
});
|
|
93
|
+
const never = await account("never");
|
|
94
|
+
await account("fresh", { lastSyncAt: NOW - 60_000 });
|
|
95
|
+
|
|
96
|
+
const { enqueued } = await tick();
|
|
97
|
+
|
|
98
|
+
assert.deepEqual(enqueued.sort(), [due.accountId, never.accountId].sort());
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("skips an account it cannot or should not sync, and says so once in aggregate", async () => {
|
|
102
|
+
const live = await account("live");
|
|
103
|
+
await account("unsyncable", { imapHost: "mail.invalid" });
|
|
104
|
+
await account("reauth", {
|
|
105
|
+
connectionState: ConnectionState.ReauthRequired,
|
|
106
|
+
});
|
|
107
|
+
await account("calendar", { syncedServices: [AccountService.Calendar] });
|
|
108
|
+
|
|
109
|
+
const { enqueued, result, logs } = await tick();
|
|
110
|
+
|
|
111
|
+
assert.deepEqual(enqueued, [live.accountId]);
|
|
112
|
+
assert.equal(result.skipped, 3);
|
|
113
|
+
assert.equal(logs.length, 1, "one aggregate line, no per-account noise");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Issue #1120: a sync for an account with no stored credential fenced the
|
|
117
|
+
// account's FIFO group for its whole visibility timeout.
|
|
118
|
+
it("skips an account that stores no credential", async () => {
|
|
119
|
+
const live = await account("live");
|
|
120
|
+
await account("missing", {}, { credential: false });
|
|
121
|
+
|
|
122
|
+
const { enqueued } = await tick();
|
|
123
|
+
|
|
124
|
+
assert.deepEqual(enqueued, [live.accountId]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("gives each enqueued account a scheduler-namespaced, time-bucketed dedup id", async () => {
|
|
128
|
+
const live = await account("live");
|
|
129
|
+
|
|
130
|
+
const { sent } = await tick();
|
|
131
|
+
|
|
132
|
+
const dedupId = sent[0]?.input.MessageDeduplicationId;
|
|
133
|
+
assert.ok(
|
|
134
|
+
dedupId?.startsWith(`SYNC_MAILBOXES:scheduled:${live.accountId}:`),
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("sweeps every account's attachments, due a sync or not", async () => {
|
|
139
|
+
const due = await account("due");
|
|
140
|
+
const fresh = await account("fresh", { lastSyncAt: NOW - 60_000 });
|
|
141
|
+
const swept: string[] = [];
|
|
142
|
+
|
|
143
|
+
const { result } = await tick({
|
|
144
|
+
sweepAttachments: async (item) => {
|
|
145
|
+
swept.push(item.accountId);
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
assert.deepEqual(swept.sort(), [due.accountId, fresh.accountId].sort());
|
|
150
|
+
assert.equal(result.swept, 2);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Uncontained, one storage error reaches the loop, which exits, and the
|
|
154
|
+
// container restarts without ever enqueuing an account again.
|
|
155
|
+
it("keeps enqueuing mail when the attachment sweep throws", async () => {
|
|
156
|
+
const live = await account("live");
|
|
157
|
+
|
|
158
|
+
const { enqueued, result } = await tick({
|
|
159
|
+
sweepAttachments: async () => {
|
|
160
|
+
throw new Error("AccessDenied on ListObjectsV2");
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
assert.deepEqual(enqueued, [live.accountId]);
|
|
165
|
+
assert.equal(result.sweepFailed, 1);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("keeps the tick alive when the calendar subscription refresh throws", async () => {
|
|
169
|
+
const live = await account("live");
|
|
170
|
+
|
|
171
|
+
const { enqueued, logs } = await tick({
|
|
172
|
+
refreshCalendarSubscriptions: async () => {
|
|
173
|
+
throw new Error("database is locked");
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
assert.deepEqual(enqueued, [live.accountId]);
|
|
178
|
+
assert.ok(logs.some((line) => line.level === "error"));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("counts what each account re-drove and keeps enqueuing past one that fails", async () => {
|
|
182
|
+
const live = await account("live");
|
|
183
|
+
const busy = await account("busy");
|
|
184
|
+
|
|
185
|
+
const { enqueued, result, logs } = await tick({
|
|
186
|
+
redriveStrandedPlacements: async (redriven) => {
|
|
187
|
+
if (redriven.accountId === busy.accountId) {
|
|
188
|
+
throw new Error("SQLITE_BUSY");
|
|
189
|
+
}
|
|
190
|
+
return 2;
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
assert.deepEqual(enqueued.sort(), [live.accountId, busy.accountId].sort());
|
|
195
|
+
assert.equal(result.redriven, 2);
|
|
196
|
+
assert.equal(result.redriveFailed, 1);
|
|
197
|
+
assert.ok(logs.some((line) => line.level === "error"));
|
|
198
|
+
});
|
|
199
|
+
});
|