@remit/imap-worker 0.0.25 → 0.0.27

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.25",
3
+ "version": "0.0.27",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -0,0 +1,42 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { RefreshTokenError } from "@remit/mail-oauth-service";
4
+ import { MailConnectionError } from "@remit/mailbox-service";
5
+ import { imapFailureKind } from "./failure-kind.js";
6
+
7
+ describe("imapFailureKind", () => {
8
+ it("counts an IMAP authentication rejection as auth", () => {
9
+ assert.equal(
10
+ imapFailureKind(
11
+ new MailConnectionError("auth", "IMAP authentication failed"),
12
+ ),
13
+ "auth",
14
+ );
15
+ });
16
+
17
+ it("counts a token refresh that cannot mint credentials as auth", () => {
18
+ assert.equal(
19
+ imapFailureKind(
20
+ new RefreshTokenError({
21
+ kind: "reauth-required",
22
+ code: "invalid_grant",
23
+ }),
24
+ ),
25
+ "auth",
26
+ );
27
+ });
28
+
29
+ it("keeps a network failure apart from an auth failure", () => {
30
+ assert.equal(
31
+ imapFailureKind(
32
+ new MailConnectionError("network", "IMAP connection failed: ETIMEDOUT"),
33
+ ),
34
+ "network",
35
+ );
36
+ });
37
+
38
+ it("classifies anything else as other", () => {
39
+ assert.equal(imapFailureKind(new Error("bad cursor")), "other");
40
+ assert.equal(imapFailureKind(undefined), "other");
41
+ });
42
+ });
@@ -0,0 +1,17 @@
1
+ import type { MailFailureKind } from "@remit/logger-lambda";
2
+ import { RefreshTokenError } from "@remit/mail-oauth-service";
3
+ import { MailConnectionError } from "@remit/mailbox-service";
4
+
5
+ /**
6
+ * Which class of failure ended an IMAP operation, for the exported failure
7
+ * counter (standalone-observability D3). `auth` is its own kind because it is
8
+ * the one class that never resolves itself — an expired OAuth grant or a
9
+ * changed password fails identically forever, and it is the most common way a
10
+ * self-hosted mailbox goes quiet. A refresh that cannot mint a token is the
11
+ * same condition arriving one layer earlier.
12
+ */
13
+ export const imapFailureKind = (error: unknown): MailFailureKind => {
14
+ if (error instanceof RefreshTokenError) return "auth";
15
+ if (error instanceof MailConnectionError) return error.kind;
16
+ return "other";
17
+ };
@@ -1,6 +1,6 @@
1
1
  import { getClient } from "@remit/backend/client";
2
2
  import type { Logger } from "@remit/logger-lambda";
3
- import { MetricUnit, metrics } from "@remit/logger-lambda";
3
+ import { recordImapFailure } from "@remit/logger-lambda";
4
4
  import {
5
5
  guardConnectionCursor,
6
6
  isCursorRebuildNeeded,
@@ -222,15 +222,12 @@ export const handleFlagPush = async (
222
222
  );
223
223
 
224
224
  if (outcome === "reconciled") {
225
- metrics.addMetric(
226
- "flagPushStaleRowReconciled",
227
- MetricUnit.Count,
228
- 1,
229
- );
230
225
  return;
231
226
  }
232
227
 
233
- metrics.addMetric("flagPushFailed", MetricUnit.Count, 1);
228
+ // Terminal and never re-thrown, so the handler-outcome series
229
+ // records this record as a success. Counted here or it is invisible.
230
+ recordImapFailure("FLAG_PUSH_EXHAUSTED", "other");
234
231
  log.error(
235
232
  { error: error instanceof Error ? error.message : String(error) },
236
233
  "Flag push retry exhausted; message still exists at its mailbox",
@@ -1,6 +1,6 @@
1
1
  import { getClient } from "@remit/backend/client";
2
2
  import type { Logger } from "@remit/logger-lambda";
3
- import { MetricUnit, metrics } from "@remit/logger-lambda";
3
+ import { recordImapFailure } from "@remit/logger-lambda";
4
4
  import {
5
5
  guardConnectionCursor,
6
6
  type IImapConnection,
@@ -428,11 +428,6 @@ export const handlePlacementMovePush = async (
428
428
  );
429
429
 
430
430
  if (outcome === "reconciled") {
431
- metrics.addMetric(
432
- "placementMoveStaleRowReconciled",
433
- MetricUnit.Count,
434
- 1,
435
- );
436
431
  await emitMoveResync(emitEvent, {
437
432
  accountId,
438
433
  sourceMailboxId: marker.sourceMailboxId,
@@ -441,7 +436,9 @@ export const handlePlacementMovePush = async (
441
436
  return;
442
437
  }
443
438
 
444
- metrics.addMetric("placementMoveFailed", MetricUnit.Count, 1);
439
+ // Terminal and never re-thrown, so the handler-outcome series
440
+ // records this record as a success. Counted here or it is invisible.
441
+ recordImapFailure("PLACEMENT_MOVE_EXHAUSTED", "other");
445
442
  log.error(
446
443
  { error: error instanceof Error ? error.message : String(error) },
447
444
  "Placement move retry exhausted; message still exists at its source",
@@ -1,6 +1,6 @@
1
1
  import { getClient } from "@remit/backend/client";
2
2
  import type { Logger } from "@remit/logger-lambda";
3
- import { MetricUnit, metrics } from "@remit/logger-lambda";
3
+ import { recordImapFailure } from "@remit/logger-lambda";
4
4
  import {
5
5
  BodySyncService,
6
6
  guardConnectionCursor,
@@ -157,6 +157,7 @@ export const syncMessageBody = async (
157
157
  secrets,
158
158
  mailboxSpecialUse: mailboxSpecialUseRepository,
159
159
  quarantine: quarantineRepository,
160
+ flagQueue: flagQueueService,
160
161
  } = await getClient();
161
162
 
162
163
  const account = await accountService.get(accountId);
@@ -270,6 +271,7 @@ export const syncMessageBody = async (
270
271
  uidValidity: mailbox.uidValidity ?? 0,
271
272
  attempts: receiveCount,
272
273
  },
274
+ { flagQueueService },
273
275
  );
274
276
 
275
277
  // Guard at the openBox choke point (epic #1281 invariants 3 & 5). The
@@ -320,35 +322,29 @@ export const syncMessageBody = async (
320
322
  // never a steady state (epic #1281 invariant 3). Resolve every
321
323
  // failed id into exactly one of the two terminal outcomes instead of
322
324
  // letting the record dead-letter with no diagnosis.
323
- const { reconciledMessageIds, brokenMessageIds } =
324
- await resolveExhaustedBodySyncFailures(
325
- {
326
- messageService,
327
- threadMessageService,
328
- storageService: storage,
329
- log,
330
- },
331
- {
332
- accountId,
333
- accountConfigId: account.accountConfigId,
334
- mailboxId,
335
- mailboxPath: mailbox.fullPath,
336
- failedMessageIds: result.failedMessageIds,
337
- getConnection: getConnectionChecked,
338
- },
339
- );
325
+ const { brokenMessageIds } = await resolveExhaustedBodySyncFailures(
326
+ {
327
+ messageService,
328
+ threadMessageService,
329
+ storageService: storage,
330
+ log,
331
+ },
332
+ {
333
+ accountId,
334
+ accountConfigId: account.accountConfigId,
335
+ mailboxId,
336
+ mailboxPath: mailbox.fullPath,
337
+ failedMessageIds: result.failedMessageIds,
338
+ getConnection: getConnectionChecked,
339
+ },
340
+ );
340
341
 
341
- if (reconciledMessageIds.length > 0) {
342
- metrics.addMetric(
343
- "bodySyncStaleRowReconciled",
344
- MetricUnit.Count,
345
- reconciledMessageIds.length,
346
- );
347
- }
342
+ // A broken body is terminal on a record the handler acks, so the
343
+ // handler-outcome series never sees it.
348
344
  if (brokenMessageIds.length > 0) {
349
- metrics.addMetric(
350
- "bodySyncMessageBroken",
351
- MetricUnit.Count,
345
+ recordImapFailure(
346
+ "SYNC_MESSAGE_BODY_BROKEN",
347
+ "other",
352
348
  brokenMessageIds.length,
353
349
  );
354
350
  }
@@ -14,7 +14,7 @@ import type {
14
14
  IUnitOfWork,
15
15
  } from "@remit/data-ports";
16
16
  import { SyncPhase } from "@remit/domain-enums";
17
- import { type Logger, MetricUnit, metrics } from "@remit/logger-lambda";
17
+ import { type Logger, recordImapFailure } from "@remit/logger-lambda";
18
18
  import { RefreshTokenError } from "@remit/mail-oauth-service";
19
19
  import {
20
20
  createManagedConnectionFactory,
@@ -420,22 +420,13 @@ const syncMailboxMessages = async (
420
420
  "Message sync batch complete",
421
421
  );
422
422
 
423
- if (result.syncedCount > 0) {
424
- metrics.addMetric(
425
- "imapMessagesSynced",
426
- MetricUnit.Count,
427
- result.syncedCount,
428
- );
429
- }
430
-
431
423
  // A stalled cursor is the one failure on this path that produces no error:
432
424
  // unapplicable messages are caught and held back, so the round returns
433
425
  // normally, the SQS record is deleted, and neither redrive nor the DLQ ever
434
- // engages. Without this metric the mailbox stops syncing behind a worker
435
- // that looks healthy. It is emitted as a count so a sustained non-zero
436
- // value alarms, per-mailbox detail being in the accompanying ERROR log.
426
+ // engages. Without this counter the mailbox stops syncing behind a worker
427
+ // that looks healthy. Per-mailbox detail is in the accompanying ERROR log.
437
428
  if (result.cursorStalled) {
438
- metrics.addMetric("imapSyncCursorStalled", MetricUnit.Count, 1);
429
+ recordImapFailure("SYNC_MESSAGES_CURSOR_STALLED", "other");
439
430
  }
440
431
 
441
432
  // Emit body sync events for the messages we just synced. Each event carries
package/src/index.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import {
2
2
  createLogger,
3
- MetricUnit,
4
- metrics,
3
+ queueNameFromEventSource,
4
+ recordImapFailure,
5
+ recordQueueEvent,
5
6
  withTelemetry,
6
7
  } from "@remit/logger-lambda";
7
8
  import type { SQSBatchResponse, SQSEvent, SQSHandler } from "aws-lambda";
8
9
  import type { WorkerEvent } from "./events.js";
10
+ import { imapFailureKind } from "./failure-kind.js";
9
11
  import { processEvent } from "./processor.js";
10
12
 
11
13
  const log = createLogger();
@@ -39,15 +41,16 @@ export const handler: SQSHandler = withTelemetry(
39
41
  "Processing event",
40
42
  );
41
43
 
42
- metrics.addDimension("operation", imapEvent.type);
44
+ const queue = queueNameFromEventSource(record.eventSourceARN);
43
45
  const opStart = Date.now();
44
46
  const failed = await processEvent(imapEvent, log, receiveCount)
45
47
  .then(() => {
46
- metrics.addMetric(
47
- "imapOperationLatency",
48
- MetricUnit.Milliseconds,
49
- Date.now() - opStart,
50
- );
48
+ recordQueueEvent({
49
+ queue,
50
+ eventType: imapEvent.type,
51
+ outcome: "success",
52
+ durationMs: Date.now() - opStart,
53
+ });
51
54
  return false;
52
55
  })
53
56
  .catch((error) => {
@@ -55,10 +58,15 @@ export const handler: SQSHandler = withTelemetry(
55
58
  { error, messageId: record.messageId },
56
59
  "Event processing failed",
57
60
  );
58
- metrics.addMetric("imapOperationFailures", MetricUnit.Count, 1);
61
+ recordQueueEvent({
62
+ queue,
63
+ eventType: imapEvent.type,
64
+ outcome: "failure",
65
+ durationMs: Date.now() - opStart,
66
+ });
67
+ recordImapFailure(imapEvent.type, imapFailureKind(error));
59
68
  return true;
60
69
  });
61
- metrics.clearDimensions();
62
70
 
63
71
  if (failed) {
64
72
  batchItemFailures.push({ itemIdentifier: record.messageId });
package/src/poller.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createLogger } from "@remit/logger-lambda";
1
+ import { createLogger, startMetricsServer } from "@remit/logger-lambda";
2
2
  import { runQueuePoller } from "@remit/sqs-client/poller";
3
3
  import { env } from "expect-env";
4
4
  import { handler } from "./index.js";
@@ -12,6 +12,11 @@ import { handler } from "./index.js";
12
12
  */
13
13
  const log = createLogger();
14
14
 
15
+ // /metrics and nothing else, on the compose network (standalone-observability
16
+ // D2). No health route on it: worker liveness is a heartbeat file, which keeps
17
+ // answering when this server does not.
18
+ startMetricsServer();
19
+
15
20
  await runQueuePoller({
16
21
  log,
17
22
  targets: [