@remit/imap-worker 0.0.83 → 0.0.85
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 +2 -1
- package/src/handlers/imported-folder.test.ts +63 -0
- package/src/handlers/imported-folder.ts +38 -0
- package/src/handlers/sync-mailboxes.ts +24 -1
- package/src/scheduler/calendar-subscriptions.test.ts +159 -0
- package/src/scheduler/calendar-subscriptions.ts +103 -0
- package/src/scheduler/handler.ts +2 -0
- package/src/scheduler/loop.test.ts +2 -0
- package/src/scheduler/run-tick.test.ts +63 -0
- package/src/scheduler/run-tick.ts +47 -2
- package/src/scheduler/runner.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/imap-worker",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.85",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"@remit/search-index-worker": "*",
|
|
42
42
|
"@remit/sqs-client": "*",
|
|
43
43
|
"@remit/backend": "*",
|
|
44
|
+
"@remit/calendar-service": "*",
|
|
44
45
|
"@remit/domain-enums": "*",
|
|
45
46
|
"@remit/mail-oauth-service": "*",
|
|
46
47
|
"@remit/secrets-service": "*",
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { MailboxItem } from "@remit/data-ports";
|
|
4
|
+
import type { CreateMailboxQueueInput } from "@remit/mailbox-service";
|
|
5
|
+
import { createImportedFolder } from "./imported-folder.js";
|
|
6
|
+
|
|
7
|
+
const inbox = {
|
|
8
|
+
mailboxId: "mbx-inbox",
|
|
9
|
+
accountId: "acc-1",
|
|
10
|
+
fullPath: "INBOX",
|
|
11
|
+
namespaceType: "personal",
|
|
12
|
+
namespacePrefix: "INBOX.",
|
|
13
|
+
hierarchyDelimiter: ".",
|
|
14
|
+
} as MailboxItem;
|
|
15
|
+
|
|
16
|
+
describe("createImportedFolder (#1103)", () => {
|
|
17
|
+
it("creates the folder through the pending folder-create path, in the INBOX namespace", async () => {
|
|
18
|
+
const calls: [CreateMailboxQueueInput, string, boolean | undefined][] = [];
|
|
19
|
+
const create = createImportedFolder(
|
|
20
|
+
{ findByPath: async () => inbox },
|
|
21
|
+
{
|
|
22
|
+
createMailbox: async (input, accountId, subscribe) => {
|
|
23
|
+
calls.push([input, accountId, subscribe]);
|
|
24
|
+
return {
|
|
25
|
+
outcome: "Created" as const,
|
|
26
|
+
mailbox: {
|
|
27
|
+
...inbox,
|
|
28
|
+
mailboxId: "mbx-new",
|
|
29
|
+
fullPath: input.fullPath,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const created = await create("acc-1", "INBOX.Facturen");
|
|
37
|
+
|
|
38
|
+
assert.equal(
|
|
39
|
+
created.outcome === "Created" && created.mailbox.mailboxId,
|
|
40
|
+
"mbx-new",
|
|
41
|
+
);
|
|
42
|
+
assert.equal(calls.length, 1);
|
|
43
|
+
const [input, accountId, subscribe] = calls[0];
|
|
44
|
+
assert.equal(accountId, "acc-1");
|
|
45
|
+
assert.equal(subscribe, true);
|
|
46
|
+
assert.equal(input.fullPath, "INBOX.Facturen");
|
|
47
|
+
assert.equal(input.namespacePrefix, "INBOX.");
|
|
48
|
+
assert.equal(input.hierarchyDelimiter, ".");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("refuses to guess a namespace for an account with no INBOX", async () => {
|
|
52
|
+
const create = createImportedFolder(
|
|
53
|
+
{ findByPath: async () => null },
|
|
54
|
+
{
|
|
55
|
+
createMailbox: async () => {
|
|
56
|
+
throw new Error("must not be called");
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
await assert.rejects(create("acc-1", "Facturen"), /no INBOX/);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { CreateImportedFolder } from "@remit/config-transfer";
|
|
2
|
+
import type { IMailboxRepository } from "@remit/data-ports";
|
|
3
|
+
import type { MailboxQueueService } from "@remit/mailbox-service";
|
|
4
|
+
|
|
5
|
+
export const createImportedFolder =
|
|
6
|
+
(
|
|
7
|
+
mailbox: Pick<IMailboxRepository, "findByPath">,
|
|
8
|
+
queue: Pick<MailboxQueueService, "createMailbox">,
|
|
9
|
+
): CreateImportedFolder =>
|
|
10
|
+
async (accountId, folderPath) => {
|
|
11
|
+
const inbox = await mailbox.findByPath(accountId, "INBOX");
|
|
12
|
+
if (!inbox) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`Account ${accountId} has no INBOX to take a namespace from, so "${folderPath}" cannot be created`,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
return queue.createMailbox(
|
|
18
|
+
{
|
|
19
|
+
accountId,
|
|
20
|
+
namespaceType: inbox.namespaceType,
|
|
21
|
+
namespacePrefix: inbox.namespacePrefix,
|
|
22
|
+
hierarchyDelimiter: inbox.hierarchyDelimiter,
|
|
23
|
+
fullPath: folderPath,
|
|
24
|
+
uidValidity: 0,
|
|
25
|
+
uidNext: 1,
|
|
26
|
+
highestModseq: "0",
|
|
27
|
+
messageCount: 0,
|
|
28
|
+
unseenCount: 0,
|
|
29
|
+
deletedCount: 0,
|
|
30
|
+
totalSize: 0,
|
|
31
|
+
lastSyncUid: 0,
|
|
32
|
+
highWaterMarkUid: 0,
|
|
33
|
+
lastMessageSyncAt: 0,
|
|
34
|
+
},
|
|
35
|
+
accountId,
|
|
36
|
+
true,
|
|
37
|
+
);
|
|
38
|
+
};
|
|
@@ -3,6 +3,7 @@ import { writeFolderRoleAppointment } from "@remit/backend/folder-role-appointme
|
|
|
3
3
|
import {
|
|
4
4
|
bindImportedFolders,
|
|
5
5
|
type ConfigBinderDeps,
|
|
6
|
+
disableFiltersMissingFolders,
|
|
6
7
|
} from "@remit/config-transfer";
|
|
7
8
|
import type {
|
|
8
9
|
AccountItem,
|
|
@@ -16,10 +17,12 @@ import type { Logger } from "@remit/logger-lambda";
|
|
|
16
17
|
import { RefreshTokenError } from "@remit/mail-oauth-service";
|
|
17
18
|
import {
|
|
18
19
|
createConnectionWithCredentials,
|
|
20
|
+
MailboxQueueService,
|
|
19
21
|
MailboxSyncService,
|
|
20
22
|
MailConnectionError,
|
|
21
23
|
type MailCredentials,
|
|
22
24
|
} from "@remit/mailbox-service";
|
|
25
|
+
import { env } from "expect-env";
|
|
23
26
|
import pMap from "p-map";
|
|
24
27
|
import {
|
|
25
28
|
isAccountDeleted,
|
|
@@ -30,6 +33,7 @@ import { emitEvent } from "../emit.js";
|
|
|
30
33
|
import type { SyncMailboxesEvent, SyncMessagesEvent } from "../events.js";
|
|
31
34
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
32
35
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
36
|
+
import { createImportedFolder } from "./imported-folder.js";
|
|
33
37
|
import { orderMailboxesForSync } from "./mailbox-sync-order.js";
|
|
34
38
|
|
|
35
39
|
const EVENT_EMIT_CONCURRENCY = 20;
|
|
@@ -110,6 +114,14 @@ export const syncMailboxes = async (
|
|
|
110
114
|
mailboxId,
|
|
111
115
|
lastKnownPath,
|
|
112
116
|
),
|
|
117
|
+
createFolder: createImportedFolder(
|
|
118
|
+
mailboxService,
|
|
119
|
+
new MailboxQueueService({
|
|
120
|
+
mailboxService,
|
|
121
|
+
sqsQueueUrl: env.SQS_QUEUE_URL_MAILBOX_MGMT,
|
|
122
|
+
logger: log,
|
|
123
|
+
}),
|
|
124
|
+
),
|
|
113
125
|
};
|
|
114
126
|
|
|
115
127
|
const { accountId } = event;
|
|
@@ -233,10 +245,21 @@ const syncMailboxesForAccount = async (
|
|
|
233
245
|
account.accountConfigId,
|
|
234
246
|
accountId,
|
|
235
247
|
);
|
|
236
|
-
if (
|
|
248
|
+
if (Object.values(bound).some((count) => count > 0)) {
|
|
237
249
|
log.info({ accountId, ...bound }, "Bound imported folder references");
|
|
238
250
|
}
|
|
239
251
|
|
|
252
|
+
const disabled = await disableFiltersMissingFolders(
|
|
253
|
+
binder.repositories,
|
|
254
|
+
account.accountConfigId,
|
|
255
|
+
);
|
|
256
|
+
if (disabled > 0) {
|
|
257
|
+
log.warn(
|
|
258
|
+
{ accountId, disabled },
|
|
259
|
+
"Disabled filters whose move folder no longer exists",
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
240
263
|
const allMailboxes = await collectAllMailboxes(accountId, mailboxService);
|
|
241
264
|
|
|
242
265
|
// This fan-out is where an account's IMAP work is decided: one SEARCH per
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { CalendarFeedFetcher } from "@remit/calendar-service";
|
|
4
|
+
import { MemoryCalendarStore } from "@remit/calendar-service/memory-store";
|
|
5
|
+
import type { CalendarCollectionItem } from "@remit/data-ports";
|
|
6
|
+
import { CalendarSource } from "@remit/domain-enums";
|
|
7
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
8
|
+
import { refreshDueCalendarSubscriptions } from "./calendar-subscriptions.js";
|
|
9
|
+
|
|
10
|
+
const INTERVAL_MS = 15 * 60 * 1000;
|
|
11
|
+
const NOW = 1_800_000_000_000;
|
|
12
|
+
|
|
13
|
+
const FEED = `${[
|
|
14
|
+
"BEGIN:VCALENDAR",
|
|
15
|
+
"VERSION:2.0",
|
|
16
|
+
"PRODID:-//Example//Published Calendar//EN",
|
|
17
|
+
"BEGIN:VEVENT",
|
|
18
|
+
"UID:ferry@calendar.example",
|
|
19
|
+
"DTSTAMP:20260901T000000Z",
|
|
20
|
+
"DTSTART:20260908T070000Z",
|
|
21
|
+
"DTEND:20260908T080000Z",
|
|
22
|
+
"SUMMARY:Ferry crossing",
|
|
23
|
+
"END:VEVENT",
|
|
24
|
+
"END:VCALENDAR",
|
|
25
|
+
].join("\r\n")}\r\n`;
|
|
26
|
+
|
|
27
|
+
const logged = (): { log: Logger; lines: string[] } => {
|
|
28
|
+
const lines: string[] = [];
|
|
29
|
+
const capture = (...args: unknown[]) => {
|
|
30
|
+
lines.push(JSON.stringify(args));
|
|
31
|
+
};
|
|
32
|
+
const log = {
|
|
33
|
+
info: capture,
|
|
34
|
+
warn: capture,
|
|
35
|
+
error: capture,
|
|
36
|
+
debug: capture,
|
|
37
|
+
fatal: capture,
|
|
38
|
+
trace: capture,
|
|
39
|
+
child: () => log,
|
|
40
|
+
} as unknown as Logger;
|
|
41
|
+
return { log, lines };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const subscription = async (
|
|
45
|
+
store: MemoryCalendarStore,
|
|
46
|
+
urlSegment: string,
|
|
47
|
+
checkedAt: number,
|
|
48
|
+
): Promise<CalendarCollectionItem> => {
|
|
49
|
+
const created = await store.transaction((repos) =>
|
|
50
|
+
repos.calendarCollection.create({
|
|
51
|
+
accountConfigId: "account-config-1",
|
|
52
|
+
urlSegment,
|
|
53
|
+
displayName: urlSegment,
|
|
54
|
+
source: CalendarSource.Subscribed,
|
|
55
|
+
subscriptionUrl: `https://calendar.example/${urlSegment}/private-feedfacecafe.ics`,
|
|
56
|
+
subscriptionEnabled: true,
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
return store.transaction((repos) =>
|
|
60
|
+
repos.calendarCollection.update(
|
|
61
|
+
created.accountConfigId,
|
|
62
|
+
created.calendarId,
|
|
63
|
+
{ subscriptionCheckedAt: checkedAt },
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const recording =
|
|
69
|
+
(asked: string[], answer: (url: string) => Response): CalendarFeedFetcher =>
|
|
70
|
+
async (url) => {
|
|
71
|
+
asked.push(url);
|
|
72
|
+
return answer(url);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
describe("refreshDueCalendarSubscriptions", () => {
|
|
76
|
+
it("fetches the subscriptions that are due and skips the rest", async () => {
|
|
77
|
+
const store = new MemoryCalendarStore();
|
|
78
|
+
const due = await subscription(store, "due", NOW - INTERVAL_MS);
|
|
79
|
+
await subscription(store, "fresh", NOW - 60_000);
|
|
80
|
+
const paused = await subscription(store, "paused", 0);
|
|
81
|
+
await store.transaction((repos) =>
|
|
82
|
+
repos.calendarCollection.update(
|
|
83
|
+
paused.accountConfigId,
|
|
84
|
+
paused.calendarId,
|
|
85
|
+
{ subscriptionEnabled: false },
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
const asked: string[] = [];
|
|
89
|
+
|
|
90
|
+
const tally = await refreshDueCalendarSubscriptions(
|
|
91
|
+
{
|
|
92
|
+
calendarCollection: {
|
|
93
|
+
listEnabledSubscriptions: () =>
|
|
94
|
+
store.transaction((repos) =>
|
|
95
|
+
repos.calendarCollection.listEnabledSubscriptions(),
|
|
96
|
+
),
|
|
97
|
+
},
|
|
98
|
+
calendarUnitOfWork: store,
|
|
99
|
+
log: logged().log,
|
|
100
|
+
fetcher: recording(asked, () => new Response(FEED, { status: 200 })),
|
|
101
|
+
},
|
|
102
|
+
NOW,
|
|
103
|
+
INTERVAL_MS,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
assert.deepEqual(asked, [due.subscriptionUrl]);
|
|
107
|
+
assert.deepEqual(tally, { refreshed: 1, failed: 0, notDue: 1 });
|
|
108
|
+
const objects = await store.transaction((repos) =>
|
|
109
|
+
repos.calendarObject.listByCalendar(due.calendarId),
|
|
110
|
+
);
|
|
111
|
+
assert.deepEqual(
|
|
112
|
+
objects.map((object) => object.summary),
|
|
113
|
+
["Ferry crossing"],
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("counts a feed that fails, keeps refreshing the others, and never logs an address", async () => {
|
|
118
|
+
const store = new MemoryCalendarStore();
|
|
119
|
+
const broken = await subscription(store, "broken", 0);
|
|
120
|
+
const working = await subscription(store, "working", 0);
|
|
121
|
+
const { log, lines } = logged();
|
|
122
|
+
|
|
123
|
+
const tally = await refreshDueCalendarSubscriptions(
|
|
124
|
+
{
|
|
125
|
+
calendarCollection: {
|
|
126
|
+
listEnabledSubscriptions: () =>
|
|
127
|
+
store.transaction((repos) =>
|
|
128
|
+
repos.calendarCollection.listEnabledSubscriptions(),
|
|
129
|
+
),
|
|
130
|
+
},
|
|
131
|
+
calendarUnitOfWork: store,
|
|
132
|
+
log,
|
|
133
|
+
fetcher: recording([], (url) =>
|
|
134
|
+
url === broken.subscriptionUrl
|
|
135
|
+
? new Response("gone", { status: 410 })
|
|
136
|
+
: new Response(FEED, { status: 200 }),
|
|
137
|
+
),
|
|
138
|
+
},
|
|
139
|
+
NOW,
|
|
140
|
+
INTERVAL_MS,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
assert.deepEqual(tally, { refreshed: 1, failed: 1, notDue: 0 });
|
|
144
|
+
const stored = await store.transaction((repos) =>
|
|
145
|
+
repos.calendarCollection.get(broken.accountConfigId, broken.calendarId),
|
|
146
|
+
);
|
|
147
|
+
assert.equal(stored.subscriptionError, "the feed answered HTTP 410");
|
|
148
|
+
assert.equal(stored.subscriptionCheckedAt, NOW);
|
|
149
|
+
const refreshed = await store.transaction((repos) =>
|
|
150
|
+
repos.calendarCollection.get(working.accountConfigId, working.calendarId),
|
|
151
|
+
);
|
|
152
|
+
assert.equal(refreshed.subscriptionFetchedAt, NOW);
|
|
153
|
+
assert.ok(lines.some((line) => line.includes(broken.calendarId)));
|
|
154
|
+
assert.equal(
|
|
155
|
+
lines.some((line) => line.includes("private-feedfacecafe")),
|
|
156
|
+
false,
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { RemitClient } from "@remit/backend/client";
|
|
2
|
+
import {
|
|
3
|
+
type CalendarFeedFetcher,
|
|
4
|
+
isSubscriptionDue,
|
|
5
|
+
refreshCalendarSubscription,
|
|
6
|
+
} from "@remit/calendar-service";
|
|
7
|
+
import type {
|
|
8
|
+
ICalendarCollectionRepository,
|
|
9
|
+
ICalendarUnitOfWork,
|
|
10
|
+
} from "@remit/data-ports";
|
|
11
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
12
|
+
import pMap from "p-map";
|
|
13
|
+
|
|
14
|
+
const REFRESH_CONCURRENCY = 4;
|
|
15
|
+
|
|
16
|
+
export interface CalendarSubscriptionTally {
|
|
17
|
+
refreshed: number;
|
|
18
|
+
failed: number;
|
|
19
|
+
notDue: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CalendarSubscriptionRefreshDeps {
|
|
23
|
+
calendarCollection: Pick<
|
|
24
|
+
ICalendarCollectionRepository,
|
|
25
|
+
"listEnabledSubscriptions"
|
|
26
|
+
>;
|
|
27
|
+
calendarUnitOfWork: ICalendarUnitOfWork;
|
|
28
|
+
log: Logger;
|
|
29
|
+
fetcher?: CalendarFeedFetcher;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Re-reads every enabled subscription whose last fetch is at least
|
|
34
|
+
* `intervalMs` old. A disabled subscription is never listed, which is what
|
|
35
|
+
* stops its refresh while leaving its events in place.
|
|
36
|
+
*
|
|
37
|
+
* One feed's failure is recorded on its own collection and counted here; it
|
|
38
|
+
* never stops the others. The log names the calendar and the reason, never the
|
|
39
|
+
* URL, which for a Google secret address is the whole credential.
|
|
40
|
+
*/
|
|
41
|
+
export const refreshDueCalendarSubscriptions = async (
|
|
42
|
+
deps: CalendarSubscriptionRefreshDeps,
|
|
43
|
+
now: number,
|
|
44
|
+
intervalMs: number,
|
|
45
|
+
): Promise<CalendarSubscriptionTally> => {
|
|
46
|
+
const enabled = await deps.calendarCollection.listEnabledSubscriptions();
|
|
47
|
+
const due = enabled.filter((collection) =>
|
|
48
|
+
isSubscriptionDue(collection, now, intervalMs),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const outcomes = await pMap(
|
|
52
|
+
due,
|
|
53
|
+
(collection) =>
|
|
54
|
+
refreshCalendarSubscription(
|
|
55
|
+
deps.calendarUnitOfWork,
|
|
56
|
+
collection,
|
|
57
|
+
now,
|
|
58
|
+
deps.fetcher,
|
|
59
|
+
).then(
|
|
60
|
+
(refresh) => {
|
|
61
|
+
if (refresh.ok) return true;
|
|
62
|
+
deps.log.warn(
|
|
63
|
+
{ calendarId: collection.calendarId, reason: refresh.reason },
|
|
64
|
+
"Calendar subscription could not be refreshed",
|
|
65
|
+
);
|
|
66
|
+
return false;
|
|
67
|
+
},
|
|
68
|
+
(error: unknown) => {
|
|
69
|
+
deps.log.error(
|
|
70
|
+
{ calendarId: collection.calendarId, error },
|
|
71
|
+
"Calendar subscription refresh failed",
|
|
72
|
+
);
|
|
73
|
+
return false;
|
|
74
|
+
},
|
|
75
|
+
),
|
|
76
|
+
{ concurrency: REFRESH_CONCURRENCY },
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
refreshed: outcomes.filter(Boolean).length,
|
|
81
|
+
failed: outcomes.filter((ok) => !ok).length,
|
|
82
|
+
notDue: enabled.length - due.length,
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const buildCalendarSubscriptionRefresh =
|
|
87
|
+
(
|
|
88
|
+
client: Pick<RemitClient, "calendarCollection" | "calendarUnitOfWork">,
|
|
89
|
+
log: Logger,
|
|
90
|
+
): ((
|
|
91
|
+
now: number,
|
|
92
|
+
intervalMs: number,
|
|
93
|
+
) => Promise<CalendarSubscriptionTally>) =>
|
|
94
|
+
(now, intervalMs) =>
|
|
95
|
+
refreshDueCalendarSubscriptions(
|
|
96
|
+
{
|
|
97
|
+
calendarCollection: client.calendarCollection,
|
|
98
|
+
calendarUnitOfWork: client.calendarUnitOfWork,
|
|
99
|
+
log,
|
|
100
|
+
},
|
|
101
|
+
now,
|
|
102
|
+
intervalMs,
|
|
103
|
+
);
|
package/src/scheduler/handler.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
|
4
4
|
import type { ScheduledHandler } from "aws-lambda";
|
|
5
5
|
import { env } from "expect-env";
|
|
6
6
|
import { buildAttachmentSweep } from "./attachment-sweep.js";
|
|
7
|
+
import { buildCalendarSubscriptionRefresh } from "./calendar-subscriptions.js";
|
|
7
8
|
import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
|
|
8
9
|
import { runSchedulerTick } from "./run-tick.js";
|
|
9
10
|
|
|
@@ -45,5 +46,6 @@ export const handler: ScheduledHandler = withTelemetry(async (event) => {
|
|
|
45
46
|
// to block storage, so nothing refuses an upload against a draft that is
|
|
46
47
|
// already gone and this sweep is the only thing that collects it.
|
|
47
48
|
sweepAttachments: buildAttachmentSweep(client, log),
|
|
49
|
+
refreshCalendarSubscriptions: buildCalendarSubscriptionRefresh(client, log),
|
|
48
50
|
});
|
|
49
51
|
});
|
|
@@ -413,3 +413,66 @@ describe("runSchedulerTick and the account's synced services", () => {
|
|
|
413
413
|
assert.equal(sent.length, 0);
|
|
414
414
|
});
|
|
415
415
|
});
|
|
416
|
+
|
|
417
|
+
describe("the calendar subscription refresh inside a tick", () => {
|
|
418
|
+
const oneAccount = () => [
|
|
419
|
+
{
|
|
420
|
+
items: [baseAccount({ accountId: "acct_1", lastSyncAt: 0 })],
|
|
421
|
+
cursor: null,
|
|
422
|
+
} as unknown as AccountSchedulerPage,
|
|
423
|
+
];
|
|
424
|
+
|
|
425
|
+
it("runs once per tick, against the offline interval", async () => {
|
|
426
|
+
const { sqsClient } = fakeSqsClient();
|
|
427
|
+
const calls: [number, number][] = [];
|
|
428
|
+
|
|
429
|
+
const result = await runSchedulerTick({
|
|
430
|
+
accountService: fakeAccountService(oneAccount()),
|
|
431
|
+
sqsClient,
|
|
432
|
+
queueUrl: "https://queue.test/mailboxes",
|
|
433
|
+
log: createNoopLogger(),
|
|
434
|
+
tickIntervalMs: TICK_INTERVAL_MS,
|
|
435
|
+
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
436
|
+
now: NOW,
|
|
437
|
+
refreshCalendarSubscriptions: async (now, intervalMs) => {
|
|
438
|
+
calls.push([now, intervalMs]);
|
|
439
|
+
return { refreshed: 2, failed: 1, notDue: 4 };
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
assert.deepEqual(calls, [[NOW, OFFLINE_INTERVAL_MS]]);
|
|
444
|
+
assert.equal(result.subscriptionsRefreshed, 2);
|
|
445
|
+
assert.equal(result.subscriptionsFailed, 1);
|
|
446
|
+
assert.equal(result.enqueued, 1);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("keeps the tick alive when the refresh throws", async () => {
|
|
450
|
+
const { sqsClient, sent } = fakeSqsClient();
|
|
451
|
+
const { log, calls } = createCapturingLogger();
|
|
452
|
+
|
|
453
|
+
const result = await runSchedulerTick({
|
|
454
|
+
accountService: fakeAccountService(oneAccount()),
|
|
455
|
+
sqsClient,
|
|
456
|
+
queueUrl: "https://queue.test/mailboxes",
|
|
457
|
+
log,
|
|
458
|
+
tickIntervalMs: TICK_INTERVAL_MS,
|
|
459
|
+
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
460
|
+
now: NOW,
|
|
461
|
+
refreshCalendarSubscriptions: async () => {
|
|
462
|
+
throw new Error("database is locked");
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
assert.equal(sent.length, 1);
|
|
467
|
+
assert.equal(result.subscriptionsRefreshed, 0);
|
|
468
|
+
assert.ok(
|
|
469
|
+
calls.some(
|
|
470
|
+
(entry) =>
|
|
471
|
+
entry.level === "error" &&
|
|
472
|
+
entry.args.some((arg) =>
|
|
473
|
+
String(arg).includes("Calendar subscription refresh failed"),
|
|
474
|
+
),
|
|
475
|
+
),
|
|
476
|
+
);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
isMailSyncDisabled,
|
|
14
14
|
isUnsyncableHost,
|
|
15
15
|
} from "../account-check.js";
|
|
16
|
+
import type { CalendarSubscriptionTally } from "./calendar-subscriptions.js";
|
|
16
17
|
import {
|
|
17
18
|
SCHEDULER_ENQUEUE_CONCURRENCY,
|
|
18
19
|
SCHEDULER_PAGE_SIZE,
|
|
@@ -43,6 +44,15 @@ export interface RunSchedulerTickDeps {
|
|
|
43
44
|
* both real entry points pass it.
|
|
44
45
|
*/
|
|
45
46
|
sweepAttachments?: (account: AccountItem) => Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Re-read the calendar subscriptions whose last fetch is older than
|
|
49
|
+
* `offlineIntervalMs` — the same knob that says how stale mail may get.
|
|
50
|
+
* Optional only so a test can leave it out; both real entry points pass it.
|
|
51
|
+
*/
|
|
52
|
+
refreshCalendarSubscriptions?: (
|
|
53
|
+
now: number,
|
|
54
|
+
intervalMs: number,
|
|
55
|
+
) => Promise<CalendarSubscriptionTally>;
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
export interface SchedulerTickResult {
|
|
@@ -51,6 +61,8 @@ export interface SchedulerTickResult {
|
|
|
51
61
|
skipped: number;
|
|
52
62
|
swept: number;
|
|
53
63
|
sweepFailed: number;
|
|
64
|
+
subscriptionsRefreshed: number;
|
|
65
|
+
subscriptionsFailed: number;
|
|
54
66
|
}
|
|
55
67
|
|
|
56
68
|
/**
|
|
@@ -193,10 +205,43 @@ export const runSchedulerTick = async (
|
|
|
193
205
|
cursor = page.cursor ?? undefined;
|
|
194
206
|
} while (cursor);
|
|
195
207
|
|
|
208
|
+
let subscriptionsRefreshed = 0;
|
|
209
|
+
let subscriptionsFailed = 0;
|
|
210
|
+
if (deps.refreshCalendarSubscriptions) {
|
|
211
|
+
// After every page is enqueued, for the same reason the sweep runs after
|
|
212
|
+
// the enqueue: a slow feed must never cost an account its mail. Contained
|
|
213
|
+
// like the sweep, so an unreadable store costs a tick's refresh and not
|
|
214
|
+
// the scheduler.
|
|
215
|
+
const tally = await deps
|
|
216
|
+
.refreshCalendarSubscriptions(now, offlineIntervalMs)
|
|
217
|
+
.catch((error: unknown) => {
|
|
218
|
+
log.error({ error }, "Calendar subscription refresh failed");
|
|
219
|
+
return { refreshed: 0, failed: 0, notDue: 0 };
|
|
220
|
+
});
|
|
221
|
+
subscriptionsRefreshed = tally.refreshed;
|
|
222
|
+
subscriptionsFailed = tally.failed;
|
|
223
|
+
}
|
|
224
|
+
|
|
196
225
|
log.info(
|
|
197
|
-
{
|
|
226
|
+
{
|
|
227
|
+
scanned,
|
|
228
|
+
enqueued,
|
|
229
|
+
skipped,
|
|
230
|
+
swept,
|
|
231
|
+
sweepFailed,
|
|
232
|
+
subscriptionsRefreshed,
|
|
233
|
+
subscriptionsFailed,
|
|
234
|
+
},
|
|
198
235
|
"Scheduled-sync tick complete",
|
|
199
236
|
);
|
|
200
237
|
|
|
201
|
-
return {
|
|
238
|
+
return {
|
|
239
|
+
scanned,
|
|
240
|
+
enqueued,
|
|
241
|
+
skipped,
|
|
242
|
+
swept,
|
|
243
|
+
sweepFailed,
|
|
244
|
+
subscriptionsRefreshed,
|
|
245
|
+
subscriptionsFailed,
|
|
246
|
+
};
|
|
202
247
|
};
|
package/src/scheduler/runner.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
|
7
7
|
|
|
8
8
|
import { env } from "expect-env";
|
|
9
9
|
import { buildAttachmentSweep } from "./attachment-sweep.js";
|
|
10
|
+
import { buildCalendarSubscriptionRefresh } from "./calendar-subscriptions.js";
|
|
10
11
|
import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
|
|
11
12
|
import { runSchedulerLoop } from "./loop.js";
|
|
12
13
|
import { runSchedulerTick } from "./run-tick.js";
|
|
@@ -80,6 +81,10 @@ const runLoop = async (): Promise<void> => {
|
|
|
80
81
|
tickIntervalMs,
|
|
81
82
|
offlineIntervalMs,
|
|
82
83
|
sweepAttachments: buildAttachmentSweep(client, log),
|
|
84
|
+
refreshCalendarSubscriptions: buildCalendarSubscriptionRefresh(
|
|
85
|
+
client,
|
|
86
|
+
log,
|
|
87
|
+
),
|
|
83
88
|
},
|
|
84
89
|
heartbeat: createHeartbeat("tick"),
|
|
85
90
|
onHeartbeatError: onBeatError,
|