@remit/imap-worker 0.0.37 → 0.0.39

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.37",
3
+ "version": "0.0.39",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -32,8 +32,8 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@aws-sdk/client-s3": "^3.1055.0",
35
- "nodemailer": "^9.0.3",
36
35
  "@remit/data-ports": "*",
36
+ "@remit/smtp-service": "*",
37
37
  "@remit/logger-lambda": "*",
38
38
  "@remit/mailbox-service": "*",
39
39
  "@remit/search-index-worker": "*",
@@ -47,8 +47,7 @@
47
47
  "@aws-sdk/client-ssm": "*",
48
48
  "expect-env": "*",
49
49
  "p-map": "*",
50
- "@types/aws-lambda": "*",
51
- "@types/nodemailer": "*"
50
+ "@types/aws-lambda": "*"
52
51
  },
53
52
  "license": "MIT",
54
53
  "publishConfig": {
@@ -89,6 +89,9 @@ const deps = (): AppendSentMessageDeps =>
89
89
  get: async () => h.outbox,
90
90
  delete: record("outboxMessage.delete"),
91
91
  },
92
+ outboxAttachment: {
93
+ discardAll: record("outboxAttachment.discardAll"),
94
+ },
92
95
  mailboxSpecialUse: {
93
96
  findBySpecialUse: async () => h.specialUseSent,
94
97
  },
@@ -142,6 +145,19 @@ describe("handleAppendSentMessage", () => {
142
145
  assert.equal(h.disconnectCount, 1);
143
146
  });
144
147
 
148
+ it("takes the draft's stored attachments with the row (#679)", async () => {
149
+ await handleAppendSentMessage(event, noopLog, deps());
150
+
151
+ // The row is the only reference to those objects. Sweeping has to happen
152
+ // here too, not only on a discard, or a sent message leaves its files
153
+ // behind with nothing able to reach them.
154
+ assert.deepEqual(called("outboxAttachment.discardAll")[0]?.args, [
155
+ "cfg-1",
156
+ "acc-1",
157
+ "out-1",
158
+ ]);
159
+ });
160
+
145
161
  it("builds the message from the outbox row's own headers", async () => {
146
162
  await handleAppendSentMessage(event, noopLog, deps());
147
163
 
@@ -2,11 +2,13 @@ import { getClient } from "@remit/backend/client";
2
2
  import type {
3
3
  IMailboxRepository,
4
4
  IMailboxSpecialUseRepository,
5
- OutboxMessageItem,
6
5
  } from "@remit/data-ports";
7
6
  import { MailboxSpecialUse } from "@remit/domain-enums";
8
7
  import type { Logger } from "@remit/logger-lambda";
9
- import nodemailer from "nodemailer";
8
+ import {
9
+ buildMailMessage,
10
+ renderRawMessage,
11
+ } from "@remit/smtp-service/message-builder";
10
12
  import { isAccountDeleted } from "../account-check.js";
11
13
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
12
14
  import type { AppendSentMessageEvent } from "../events.js";
@@ -46,35 +48,6 @@ const findSentMailbox = async (
46
48
  return null;
47
49
  };
48
50
 
49
- const buildRawMessage = async (outbox: OutboxMessageItem): Promise<Buffer> => {
50
- const from = outbox.fromName
51
- ? `${outbox.fromName} <${outbox.fromAddress}>`
52
- : outbox.fromAddress;
53
-
54
- const transport = nodemailer.createTransport({ streamTransport: true });
55
-
56
- const info = await transport.sendMail({
57
- from,
58
- to: outbox.toAddresses.join(", "),
59
- cc: outbox.ccAddresses?.join(", "),
60
- bcc: outbox.bccAddresses?.join(", "),
61
- replyTo: outbox.replyToAddress,
62
- subject: outbox.subject,
63
- text: outbox.textBody,
64
- html: outbox.htmlBody,
65
- messageId: `<${outbox.messageIdValue}>`,
66
- inReplyTo: outbox.inReplyTo ? `<${outbox.inReplyTo}>` : undefined,
67
- references: outbox.references?.map((r) => `<${r}>`).join(" "),
68
- date: outbox.sentAt ? new Date(outbox.sentAt) : new Date(),
69
- });
70
-
71
- const chunks: Buffer[] = [];
72
- for await (const chunk of info.message as AsyncIterable<Buffer>) {
73
- chunks.push(chunk);
74
- }
75
- return Buffer.concat(chunks);
76
- };
77
-
78
51
  export interface AppendSentMessageDeps {
79
52
  getClient: typeof getClient;
80
53
  buildLifecycleDeps: typeof buildLifecycleDeps;
@@ -104,6 +77,7 @@ export const handleAppendSentMessage = async (
104
77
  const {
105
78
  account: accountService,
106
79
  outboxMessage: outboxMessageService,
80
+ outboxAttachment: outboxAttachmentService,
107
81
  mailboxSpecialUse: mailboxSpecialUseService,
108
82
  mailbox: mailboxService,
109
83
  secrets,
@@ -150,7 +124,7 @@ export const handleAppendSentMessage = async (
150
124
  await scope
151
125
  .getConnection()
152
126
  .then(async (connection) => {
153
- const rawMessage = await buildRawMessage(outbox);
127
+ const rawMessage = await renderRawMessage(buildMailMessage(outbox));
154
128
 
155
129
  const result = await connection.append(
156
130
  sentMailbox.fullPath,
@@ -172,10 +146,23 @@ export const handleAppendSentMessage = async (
172
146
 
173
147
  // The message now lives in the IMAP Sent folder. Drop the outbox row so
174
148
  // the user does not see it twice in the UI (Outbox + Sent). Issue #178.
149
+ //
150
+ // The row goes first, and nothing that can fail may come before it. The
151
+ // APPEND above has already happened; anything between it and this delete
152
+ // that throws leaves the row for the job to retry, and the retry appends
153
+ // a second copy to the user's Sent folder. A storage error is not worth
154
+ // a duplicate message — the attachment objects that outlive their row
155
+ // are exactly what the sweep collects, so losing this delete costs a
156
+ // sweep and nothing else.
175
157
  await outboxMessageService.delete(
176
158
  account.accountConfigId,
177
159
  outboxMessageId,
178
160
  );
161
+ await outboxAttachmentService.discardAll(
162
+ account.accountConfigId,
163
+ accountId,
164
+ outboxMessageId,
165
+ );
179
166
  log.info(
180
167
  { outboxMessageId },
181
168
  "Deleted outbox row after successful APPEND to Sent",
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Both scheduler entry points must hand the tick a sweep.
3
+ *
4
+ * Only the hosted one strictly needs it — a browser PUTs straight to block
5
+ * storage there, so nothing is in the path to refuse an upload against a draft
6
+ * that is already gone — but a deployment that quietly stops collecting is
7
+ * indistinguishable from one that has nothing to collect. Deleting either wiring
8
+ * used to leave the suite green.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { readFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { describe, it } from "node:test";
15
+ import { fileURLToPath } from "node:url";
16
+ import type { AccountItem } from "@remit/data-ports";
17
+ import type { Logger } from "@remit/logger-lambda";
18
+ import type { StorageService } from "@remit/storage-service";
19
+ import { buildAttachmentSweep } from "./attachment-sweep.js";
20
+
21
+ const here = dirname(fileURLToPath(import.meta.url));
22
+ const TICK_TIME = 1_700_000_000_000;
23
+
24
+ const silentLogger = (): Logger => {
25
+ const noop = () => {};
26
+ const log = {
27
+ info: noop,
28
+ warn: noop,
29
+ error: noop,
30
+ debug: noop,
31
+ fatal: noop,
32
+ trace: noop,
33
+ child: () => log,
34
+ } as unknown as Logger;
35
+ return log;
36
+ };
37
+
38
+ /** Enough of an account for the tick's eligibility checks to run. */
39
+ const eligibleAccount = (accountId: string) =>
40
+ ({
41
+ accountId,
42
+ accountConfigId: "cfg-1",
43
+ email: `${accountId}@example.com`,
44
+ username: accountId,
45
+ authType: "password",
46
+ imapHost: "imap.example.com",
47
+ imapPort: 993,
48
+ imapTls: true,
49
+ imapStartTls: false,
50
+ isActive: true,
51
+ connectionState: "authenticated",
52
+ // Freshly synced, so nothing is due and no SQS send is attempted. The
53
+ // sweep must still run: abandoned bytes have nothing to do with how
54
+ // recently mail was fetched.
55
+ lastSyncAt: TICK_TIME,
56
+ createdAt: 0,
57
+ updatedAt: 0,
58
+ }) as unknown as AccountItem;
59
+
60
+ describe("the hosted tick sweeps", () => {
61
+ it("runs the sweep for every account, due a sync or not", async () => {
62
+ process.env.SQS_QUEUE_URL_MAILBOXES = "https://queue.test/mailboxes";
63
+
64
+ const swept: string[] = [];
65
+ const { setClient, _resetForTest } = await import("@remit/backend/client");
66
+ setClient({
67
+ account: {
68
+ listAllAccountsPage: async () => ({
69
+ items: [eligibleAccount("acc-1"), eligibleAccount("acc-2")],
70
+ cursor: null,
71
+ }),
72
+ },
73
+ storage: {
74
+ listOutboxDraftsWithAttachments: async (
75
+ _cfg: string,
76
+ accountId: string,
77
+ ) => {
78
+ swept.push(accountId);
79
+ return [];
80
+ },
81
+ },
82
+ outboxAttachment: { reapAndListLive: async () => [] },
83
+ } as never);
84
+
85
+ try {
86
+ const { handler } = await import("./handler.js");
87
+ await handler(
88
+ { time: new Date(TICK_TIME).toISOString() } as never,
89
+ {} as never,
90
+ () => {},
91
+ );
92
+ assert.deepEqual(swept.sort(), ["acc-1", "acc-2"]);
93
+ } finally {
94
+ _resetForTest();
95
+ }
96
+ });
97
+ });
98
+
99
+ describe("the self-host runner wires the sweep", () => {
100
+ it("hands runSchedulerTick a sweep", () => {
101
+ // runner.ts starts its loop on import, so it cannot be invoked from a test
102
+ // the way handler.ts can. This guards the wiring itself: without it,
103
+ // deleting the line leaves the suite green and self-host silently stops
104
+ // collecting.
105
+ const source = readFileSync(join(here, "runner.ts"), "utf8");
106
+ assert.match(source, /sweepAttachments:\s*buildAttachmentSweep\(/);
107
+ });
108
+ });
109
+
110
+ describe("buildAttachmentSweep", () => {
111
+ const account = {
112
+ accountId: "acc-1",
113
+ accountConfigId: "cfg-1",
114
+ } as unknown as AccountItem;
115
+
116
+ it("collects the objects the database does not name", async () => {
117
+ const deleted: string[] = [];
118
+ const storage = {
119
+ listOutboxDraftsWithAttachments: async () => ["draft-1"],
120
+ listOutboxAttachments: async () => [
121
+ { outboxAttachmentId: "known", key: "k1", sizeBytes: 1 },
122
+ { outboxAttachmentId: "orphan", key: "k2", sizeBytes: 1 },
123
+ ],
124
+ deleteOutboxAttachment: async (
125
+ _cfg: string,
126
+ _acc: string,
127
+ _draft: string,
128
+ id: string,
129
+ ) => {
130
+ deleted.push(id);
131
+ },
132
+ } as unknown as StorageService;
133
+
134
+ await buildAttachmentSweep(
135
+ {
136
+ storage,
137
+ outboxAttachment: {
138
+ reapAndListLive: async () => ["known"],
139
+ },
140
+ } as never,
141
+ silentLogger(),
142
+ )(account);
143
+
144
+ assert.deepEqual(deleted, ["orphan"]);
145
+ });
146
+
147
+ it("lets a storage failure out, so the tick can count it", async () => {
148
+ // Containment belongs to the tick, which keeps enqueuing mail regardless.
149
+ // Swallowing here would make a broken sweep look like a working one.
150
+ const storage = {
151
+ listOutboxDraftsWithAttachments: async () => {
152
+ throw new Error("storage root does not exist");
153
+ },
154
+ } as unknown as StorageService;
155
+
156
+ await assert.rejects(
157
+ () =>
158
+ buildAttachmentSweep(
159
+ {
160
+ storage,
161
+ outboxAttachment: { reapAndListLive: async () => [] },
162
+ } as never,
163
+ silentLogger(),
164
+ )(account),
165
+ /storage root does not exist/,
166
+ );
167
+ });
168
+ });
@@ -0,0 +1,46 @@
1
+ import type { RemitClient } from "@remit/backend/client";
2
+ import type { AccountItem } from "@remit/data-ports";
3
+ import type { Logger } from "@remit/logger-lambda";
4
+ import { sweepAbandonedOutboxAttachments } from "@remit/storage-service";
5
+ /**
6
+ * Collect attachment bytes the database does not know about. Both scheduler
7
+ * entry points build it the same way, so the hosted tick and the self-host loop
8
+ * collect identically — this is the only defence on the hosted side, where the
9
+ * browser PUTs straight to block storage and nothing consults the database on
10
+ * the way in.
11
+ */
12
+ export const buildAttachmentSweep =
13
+ (
14
+ client: Pick<RemitClient, "storage" | "outboxAttachment">,
15
+ log: Logger,
16
+ ): ((account: AccountItem) => Promise<void>) =>
17
+ async (account) => {
18
+ const { deleted, skipped } = await sweepAbandonedOutboxAttachments(
19
+ {
20
+ storage: client.storage,
21
+ // Reaps the draft's lapsed reservations first, so a row that has
22
+ // stopped holding room stops vouching for its bytes too and the
23
+ // object is collected on this same pass.
24
+ listKnownAttachmentIds: (accountConfigId, outboxMessageId) =>
25
+ client.outboxAttachment.reapAndListLive(
26
+ accountConfigId,
27
+ outboxMessageId,
28
+ ),
29
+ onSkipped: (outboxMessageId, error) => {
30
+ log.error(
31
+ { outboxMessageId, error },
32
+ "Left a draft's attachment objects alone: its rows could not be read",
33
+ );
34
+ },
35
+ },
36
+ account.accountConfigId,
37
+ account.accountId,
38
+ );
39
+
40
+ if (deleted > 0 || skipped > 0) {
41
+ log.warn(
42
+ { accountId: account.accountId, deleted, skipped },
43
+ "Outbox attachment sweep",
44
+ );
45
+ }
46
+ };
@@ -3,6 +3,7 @@ import { createLogger, withTelemetry } from "@remit/logger-lambda";
3
3
  import { createQueueProducer } from "@remit/sqs-client/producer";
4
4
  import type { ScheduledHandler } from "aws-lambda";
5
5
  import { env } from "expect-env";
6
+ import { buildAttachmentSweep } from "./attachment-sweep.js";
6
7
  import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
7
8
  import { runSchedulerTick } from "./run-tick.js";
8
9
 
@@ -29,16 +30,20 @@ const sqsClient = createQueueProducer({ queueUrl: mailboxesQueueUrl });
29
30
  * correctly dedupes.
30
31
  */
31
32
  export const handler: ScheduledHandler = withTelemetry(async (event) => {
32
- const { account } = await getClient();
33
+ const client = await getClient();
33
34
  const now = Date.parse(event.time);
34
35
 
35
36
  await runSchedulerTick({
36
- accountService: account,
37
+ accountService: client.account,
37
38
  sqsClient,
38
39
  queueUrl: mailboxesQueueUrl,
39
40
  log,
40
41
  tickIntervalMs: getTickIntervalMs(),
41
42
  offlineIntervalMs: getOfflineIntervalMs(),
42
43
  now,
44
+ // The hosted side is the one that needs this: a browser PUTs straight
45
+ // to block storage, so nothing refuses an upload against a draft that is
46
+ // already gone and this sweep is the only thing that collects it.
47
+ sweepAttachments: buildAttachmentSweep(client, log),
43
48
  });
44
49
  });
@@ -3,7 +3,13 @@ import { describe, it } from "node:test";
3
3
  import { runSchedulerLoop, type SchedulerLoopOptions } from "./loop.js";
4
4
  import type { RunSchedulerTickDeps, SchedulerTickResult } from "./run-tick.js";
5
5
 
6
- const RESULT: SchedulerTickResult = { scanned: 0, enqueued: 0, skipped: 0 };
6
+ const RESULT: SchedulerTickResult = {
7
+ scanned: 0,
8
+ enqueued: 0,
9
+ skipped: 0,
10
+ swept: 0,
11
+ sweepFailed: 0,
12
+ };
7
13
 
8
14
  // Only the loop's own two calls matter here; the tick is a stub, so what it is
9
15
  // handed is never read.
@@ -246,3 +246,90 @@ describe("runSchedulerTick", () => {
246
246
  assert.equal(result.enqueued, 0);
247
247
  });
248
248
  });
249
+
250
+ describe("the attachment sweep inside a tick", () => {
251
+ const twoAccounts = () => [
252
+ {
253
+ items: [
254
+ baseAccount({ accountId: "acct_1", lastSyncAt: NOW - 1_000 }),
255
+ baseAccount({ accountId: "acct_2", lastSyncAt: 0 }),
256
+ ],
257
+ cursor: null,
258
+ } as unknown as AccountSchedulerPage,
259
+ ];
260
+
261
+ it("sweeps every account, not only the ones due a sync", async () => {
262
+ const { sqsClient } = fakeSqsClient();
263
+ const swept: string[] = [];
264
+
265
+ const result = await runSchedulerTick({
266
+ accountService: fakeAccountService(twoAccounts()),
267
+ sqsClient,
268
+ queueUrl: "https://queue.test/mailboxes",
269
+ log: createNoopLogger(),
270
+ tickIntervalMs: TICK_INTERVAL_MS,
271
+ offlineIntervalMs: OFFLINE_INTERVAL_MS,
272
+ now: NOW,
273
+ sweepAttachments: async (account) => {
274
+ swept.push(account.accountId);
275
+ },
276
+ });
277
+
278
+ assert.deepEqual(swept.sort(), ["acct_1", "acct_2"]);
279
+ assert.equal(result.swept, 2);
280
+ assert.equal(result.sweepFailed, 0);
281
+ });
282
+
283
+ it("keeps enqueuing mail when the sweep throws", async () => {
284
+ // The sweep is housekeeping; enqueuing mail is not. Uncontained, one
285
+ // storage error here reaches the loop, which exits, and the container
286
+ // restarts every few seconds without ever enqueuing an account again.
287
+ const { sqsClient, sent } = fakeSqsClient();
288
+ const { log, calls } = createCapturingLogger();
289
+
290
+ const result = await runSchedulerTick({
291
+ accountService: fakeAccountService(twoAccounts()),
292
+ sqsClient,
293
+ queueUrl: "https://queue.test/mailboxes",
294
+ log,
295
+ tickIntervalMs: TICK_INTERVAL_MS,
296
+ offlineIntervalMs: OFFLINE_INTERVAL_MS,
297
+ now: NOW,
298
+ sweepAttachments: async () => {
299
+ throw new Error("AccessDenied on ListObjectsV2");
300
+ },
301
+ });
302
+
303
+ assert.equal(result.sweepFailed, 2);
304
+ assert.equal(result.swept, 0);
305
+ // The tick still did its actual job.
306
+ assert.ok(sent.length > 0);
307
+ assert.equal(result.enqueued, sent.length);
308
+ assert.ok(
309
+ calls.some(
310
+ (entry) =>
311
+ entry.level === "error" &&
312
+ entry.args.some((arg) =>
313
+ String(arg).includes("Outbox attachment sweep failed"),
314
+ ),
315
+ ),
316
+ );
317
+ });
318
+
319
+ it("ticks without a sweep at all", async () => {
320
+ const { sqsClient } = fakeSqsClient();
321
+
322
+ const result = await runSchedulerTick({
323
+ accountService: fakeAccountService(twoAccounts()),
324
+ sqsClient,
325
+ queueUrl: "https://queue.test/mailboxes",
326
+ log: createNoopLogger(),
327
+ tickIntervalMs: TICK_INTERVAL_MS,
328
+ offlineIntervalMs: OFFLINE_INTERVAL_MS,
329
+ now: NOW,
330
+ });
331
+
332
+ assert.equal(result.swept, 0);
333
+ assert.equal(result.sweepFailed, 0);
334
+ });
335
+ });
@@ -30,12 +30,25 @@ export interface RunSchedulerTickDeps {
30
30
  tickIntervalMs: number;
31
31
  /** Injectable for tests; defaults to `Date.now()`. */
32
32
  now?: number;
33
+ /**
34
+ * Collect attachment bytes the database does not know about, for one account.
35
+ *
36
+ * The tick already walks every account on a cadence, which is the only thing
37
+ * this needs. It exists because an upload URL minted for a draft stays valid
38
+ * after that draft is discarded, and where the browser writes straight to
39
+ * block storage nothing is in the path to refuse it — see
40
+ * `sweepAbandonedOutboxAttachments`. Optional only so a test can leave it out;
41
+ * both real entry points pass it.
42
+ */
43
+ sweepAttachments?: (account: AccountItem) => Promise<void>;
33
44
  }
34
45
 
35
46
  export interface SchedulerTickResult {
36
47
  scanned: number;
37
48
  enqueued: number;
38
49
  skipped: number;
50
+ swept: number;
51
+ sweepFailed: number;
39
52
  }
40
53
 
41
54
  /**
@@ -101,6 +114,8 @@ export const runSchedulerTick = async (
101
114
  let scanned = 0;
102
115
  let enqueued = 0;
103
116
  let skipped = 0;
117
+ let swept = 0;
118
+ let sweepFailed = 0;
104
119
 
105
120
  do {
106
121
  const page = await accountService.listAllAccountsPage({
@@ -132,10 +147,49 @@ export const runSchedulerTick = async (
132
147
  );
133
148
  enqueued += due.length;
134
149
 
150
+ if (deps.sweepAttachments) {
151
+ // After the enqueue, never before it. Enqueuing mail is the tick's job
152
+ // and collecting bytes is housekeeping; on a hosted Lambda a slow sweep
153
+ // ahead of the enqueue eats the invocation budget, and a timeout is not
154
+ // catchable, so the remaining pages would never be enqueued at all.
155
+ //
156
+ // Every account, not only the ones due a sync: abandoned bytes have
157
+ // nothing to do with how recently mail was fetched. Bounded to the same
158
+ // concurrency as the enqueues.
159
+ //
160
+ // Contained, because this is housekeeping and enqueuing mail is not. An
161
+ // unreadable input or a storage permission error thrown from here would
162
+ // otherwise reach the loop, which logs and exits, and compose would
163
+ // restart the process every five seconds — no account would ever be
164
+ // enqueued again. A failed sweep costs a tick's collection and nothing
165
+ // else; the count is what makes that visible.
166
+ const sweep = deps.sweepAttachments;
167
+ const outcomes = await pMap(
168
+ page.items,
169
+ (account) =>
170
+ sweep(account).then(
171
+ () => true,
172
+ (error: unknown) => {
173
+ log.error(
174
+ { accountId: account.accountId, error },
175
+ "Outbox attachment sweep failed for account",
176
+ );
177
+ return false;
178
+ },
179
+ ),
180
+ { concurrency: SCHEDULER_ENQUEUE_CONCURRENCY },
181
+ );
182
+ swept += outcomes.filter(Boolean).length;
183
+ sweepFailed += outcomes.filter((ok) => !ok).length;
184
+ }
185
+
135
186
  cursor = page.cursor ?? undefined;
136
187
  } while (cursor);
137
188
 
138
- log.info({ scanned, enqueued, skipped }, "Scheduled-sync tick complete");
189
+ log.info(
190
+ { scanned, enqueued, skipped, swept, sweepFailed },
191
+ "Scheduled-sync tick complete",
192
+ );
139
193
 
140
- return { scanned, enqueued, skipped };
194
+ return { scanned, enqueued, skipped, swept, sweepFailed };
141
195
  };
@@ -4,7 +4,9 @@ import { getClient } from "@remit/backend/client";
4
4
  import { createLogger } from "@remit/logger-lambda";
5
5
  import { clearHeartbeats, createHeartbeat } from "@remit/sqs-client/heartbeat";
6
6
  import { createQueueProducer } from "@remit/sqs-client/producer";
7
+
7
8
  import { env } from "expect-env";
9
+ import { buildAttachmentSweep } from "./attachment-sweep.js";
8
10
  import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
9
11
  import { runSchedulerLoop } from "./loop.js";
10
12
  import { runSchedulerTick } from "./run-tick.js";
@@ -66,7 +68,8 @@ const runLoop = async (): Promise<void> => {
66
68
  };
67
69
 
68
70
  await clearHeartbeats().catch(onClearError);
69
- const { account } = await getClient();
71
+ const client = await getClient();
72
+ const { account } = client;
70
73
  await runSchedulerLoop({
71
74
  tick: runSchedulerTick,
72
75
  tickDeps: {
@@ -76,6 +79,7 @@ const runLoop = async (): Promise<void> => {
76
79
  log,
77
80
  tickIntervalMs,
78
81
  offlineIntervalMs,
82
+ sweepAttachments: buildAttachmentSweep(client, log),
79
83
  },
80
84
  heartbeat: createHeartbeat("tick"),
81
85
  onHeartbeatError: onBeatError,