@remit/smtp-worker 0.0.21 → 0.0.23

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.21",
3
+ "version": "0.0.23",
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,
@@ -72,10 +73,11 @@ export interface SendMessageDeps {
72
73
  * Resolve credentials for the account. Called after fetching the account.
73
74
  * For password accounts this may resolve immediately from the stored hash.
74
75
  * For OAuth accounts this mints an access token via the token service.
76
+ * Returns `{ status: "missing" }` when the account stores no credential.
75
77
  * Throws RefreshTokenError on OAuth failures — callers should not need to
76
78
  * handle this here; the caller of sendMessage handles it.
77
79
  */
78
- resolveCredentials: (account: AccountItem) => Promise<SmtpCredentials>;
80
+ resolveCredentials: (account: AccountItem) => Promise<CredentialResolution>;
79
81
  /**
80
82
  * Persist the account's connectionState. Called when a terminal OAuth/SMTP
81
83
  * auth failure is detected so the account is fenced off until the user
@@ -203,9 +205,26 @@ export const sendMessage = async (
203
205
  // Resolve credentials. On a terminal OAuth auth failure (token revoked),
204
206
  // flip the account to reauth_required and ACK — do not retry. Transient /
205
207
  // config failures rethrow for SQS retry/backoff.
206
- let credentials: SmtpCredentials;
208
+ let credentials: SmtpCredentials | undefined;
207
209
  try {
208
- credentials = await deps.resolveCredentials(account);
210
+ const resolution = await deps.resolveCredentials(account);
211
+ if (resolution.status === "missing") {
212
+ // Terminal, never a retry (issue #1120). The account still sends if it
213
+ // carries an SMTP-specific credential of its own, so this hands
214
+ // resolveSmtpConfig no credential and lets it decide; where there is
215
+ // none either, the send settles `blocked` below.
216
+ log.warn(
217
+ {
218
+ accountId,
219
+ reason: resolution.reason,
220
+ connectionState: resolution.terminalState,
221
+ },
222
+ "Account stores no credential",
223
+ );
224
+ await deps.updateConnectionState(accountId, resolution.terminalState);
225
+ } else {
226
+ credentials = resolution.credentials;
227
+ }
209
228
  } catch (err) {
210
229
  if (err instanceof RefreshTokenError) {
211
230
  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) => {
@@ -329,6 +332,24 @@ describe("sendMessage handler", () => {
329
332
  assert.equal(recorded.updates.length, 0);
330
333
  });
331
334
 
335
+ it("drops the event for a row the enqueue settled at `failed`", async () => {
336
+ // The other half of #936. An error from SQS `SendMessage` says the response
337
+ // was lost, not that the broker refused the event, so the row is settled
338
+ // outside this fence and the event that landed anyway dies here. Settling
339
+ // it at `draft` instead would send it — and leave the user a row they can
340
+ // send a second time.
341
+ const { deps, recorded } = buildDeps({
342
+ outbox: buildOutbox({ status: "failed" }),
343
+ });
344
+
345
+ await sendMessage(event, silentLogger, deps);
346
+
347
+ assert.equal(recorded.sendCalls, 0, "must not send");
348
+ assert.equal(recorded.marked.length, 0);
349
+ assert.equal(recorded.updates.length, 0);
350
+ assert.equal(recorded.statuses.length, 0);
351
+ });
352
+
332
353
  it("settles the row as unfiled when the append event cannot be queued", async () => {
333
354
  const { deps, recorded } = buildDeps({
334
355
  account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),
@@ -550,6 +571,25 @@ describe("sendMessage OAuth reauth/ACK contract", () => {
550
571
  });
551
572
  });
552
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
+
553
593
  it("on transient credential error: rethrows", async () => {
554
594
  const { deps, recorded } = buildDeps({
555
595
  account: buildAccount({ smtpHost: "smtp.example.com", smtpPort: 587 }),