@remit/smtp-worker 0.0.22 → 0.0.24

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/smtp-worker",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -7,6 +7,7 @@ import type {
7
7
  import { AccountAuthType, OutboxMessageStatus } from "@remit/domain-enums";
8
8
  import { type Logger, recordSmtpFailure } from "@remit/logger-lambda";
9
9
  import { RefreshTokenError } from "@remit/mail-oauth-service";
10
+ import type { CredentialResolution } from "@remit/mailbox-service/account-credentials";
10
11
  import type { SecretsService } from "@remit/secrets-service";
11
12
  import {
12
13
  buildMailMessage,
@@ -14,6 +15,7 @@ import {
14
15
  SmtpConnectionError,
15
16
  type sendMail,
16
17
  } from "@remit/smtp-service";
18
+ import { attemptBudget } from "@remit/sqs-client/attempt-budget";
17
19
  import type { SendMessageEvent } from "../events.js";
18
20
  import { writeEngagementCounters } from "./engagement-counters.js";
19
21
  import {
@@ -72,10 +74,11 @@ export interface SendMessageDeps {
72
74
  * Resolve credentials for the account. Called after fetching the account.
73
75
  * For password accounts this may resolve immediately from the stored hash.
74
76
  * For OAuth accounts this mints an access token via the token service.
77
+ * Returns `{ status: "missing" }` when the account stores no credential.
75
78
  * Throws RefreshTokenError on OAuth failures — callers should not need to
76
79
  * handle this here; the caller of sendMessage handles it.
77
80
  */
78
- resolveCredentials: (account: AccountItem) => Promise<SmtpCredentials>;
81
+ resolveCredentials: (account: AccountItem) => Promise<CredentialResolution>;
79
82
  /**
80
83
  * Persist the account's connectionState. Called when a terminal OAuth/SMTP
81
84
  * auth failure is detected so the account is fenced off until the user
@@ -131,24 +134,9 @@ const SENDABLE_STATUSES: ReadonlySet<OutboxMessageItem["status"]> = new Set([
131
134
  OutboxMessageStatus.sending,
132
135
  ]);
133
136
 
134
- /**
135
- * Fallback when `SEND_MESSAGE_MAX_ATTEMPTS` is unset (local dev, unit tests).
136
- * Matches `remit-smtp`'s `maxReceiveCount` (`deploy/vps/queues.json`), same
137
- * pattern as `MESSAGE_MOVE_MAX_ATTEMPTS` / `FLAG_PUSH_MAX_ATTEMPTS` in
138
- * imap-worker.
139
- */
140
- const DEFAULT_SEND_MESSAGE_MAX_ATTEMPTS = 3;
141
-
142
137
  export const getSendMessageMaxAttempts = (
143
138
  processEnv: NodeJS.ProcessEnv = process.env,
144
- ): number => {
145
- const raw = processEnv.SEND_MESSAGE_MAX_ATTEMPTS;
146
- if (!raw) return DEFAULT_SEND_MESSAGE_MAX_ATTEMPTS;
147
- const parsed = Number.parseInt(raw, 10);
148
- return Number.isFinite(parsed) && parsed > 0
149
- ? parsed
150
- : DEFAULT_SEND_MESSAGE_MAX_ATTEMPTS;
151
- };
139
+ ): number => attemptBudget("SEND_MESSAGE_MAX_ATTEMPTS", 3, processEnv);
152
140
 
153
141
  export const SEND_MESSAGE_MAX_ATTEMPTS = getSendMessageMaxAttempts();
154
142
 
@@ -203,9 +191,26 @@ export const sendMessage = async (
203
191
  // Resolve credentials. On a terminal OAuth auth failure (token revoked),
204
192
  // flip the account to reauth_required and ACK — do not retry. Transient /
205
193
  // config failures rethrow for SQS retry/backoff.
206
- let credentials: SmtpCredentials;
194
+ let credentials: SmtpCredentials | undefined;
207
195
  try {
208
- credentials = await deps.resolveCredentials(account);
196
+ const resolution = await deps.resolveCredentials(account);
197
+ if (resolution.status === "missing") {
198
+ // Terminal, never a retry (issue #1120). The account still sends if it
199
+ // carries an SMTP-specific credential of its own, so this hands
200
+ // resolveSmtpConfig no credential and lets it decide; where there is
201
+ // none either, the send settles `blocked` below.
202
+ log.warn(
203
+ {
204
+ accountId,
205
+ reason: resolution.reason,
206
+ connectionState: resolution.terminalState,
207
+ },
208
+ "Account stores no credential",
209
+ );
210
+ await deps.updateConnectionState(accountId, resolution.terminalState);
211
+ } else {
212
+ credentials = resolution.credentials;
213
+ }
209
214
  } catch (err) {
210
215
  if (err instanceof RefreshTokenError) {
211
216
  if (err.error.kind === "reauth-required") {
@@ -145,10 +145,13 @@ const buildDeps = (
145
145
  (async (_account) => {
146
146
  recorded.resolveCalls += 1;
147
147
  return {
148
- kind: "password" as const,
149
- password: _account.passwordHash
150
- ? "resolved-password"
151
- : "no-password-configured",
148
+ status: "resolved" as const,
149
+ credentials: {
150
+ kind: "password" as const,
151
+ password: _account.passwordHash
152
+ ? "resolved-password"
153
+ : "no-password-configured",
154
+ },
152
155
  };
153
156
  }),
154
157
  updateConnectionState: async (accountId, state) => {
@@ -568,6 +571,25 @@ describe("sendMessage OAuth reauth/ACK contract", () => {
568
571
  });
569
572
  });
570
573
 
574
+ it("account storing no credential: settles `blocked` and never retries (issue #1120)", async () => {
575
+ const { deps, recorded } = buildDeps({
576
+ account: buildAccount({ passwordHash: undefined }),
577
+ resolveCredentials: async () => ({
578
+ status: "missing" as const,
579
+ terminalState: "credentials_missing" as const,
580
+ reason: "authType=password but no passwordHash",
581
+ }),
582
+ });
583
+
584
+ await sendMessage(event, silentLogger, deps);
585
+
586
+ assert.equal(recorded.sendCalls, 0, "must not send");
587
+ assert.deepEqual(recorded.connectionStateUpdates, [
588
+ { accountId: "acc-1", state: "credentials_missing" },
589
+ ]);
590
+ assert.equal(recorded.updates[0]?.patch.status, "blocked");
591
+ });
592
+
571
593
  it("on transient credential error: rethrows", async () => {
572
594
  const { deps, recorded } = buildDeps({
573
595
  account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),