@remit/smtp-worker 0.0.30 → 0.0.31

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.
@@ -1,10 +1,14 @@
1
- import { randomUUID } from "node:crypto";
1
+ import type { OutboxMessageItem, QueueProducer } from "@remit/data-ports";
2
2
  import { deriveAddressId, deriveMessageId } from "@remit/data-ports/id";
3
- import type { ConnectionState } from "@remit/domain-enums";
4
- import type { Logger } from "@remit/logger";
5
3
  import {
6
- createMailOAuthService,
7
- microsoftProviderConfig,
4
+ AccountAuthType,
5
+ type ConnectionState,
6
+ OutboxMessageStatus,
7
+ } from "@remit/domain-enums";
8
+ import { createLogger, type MailFailureKind } from "@remit/logger";
9
+ import {
10
+ type MailOAuthService,
11
+ RefreshTokenError,
8
12
  } from "@remit/mail-oauth-service";
9
13
  // Subpath import, not the package root: the root barrel re-exports the whole
10
14
  // IMAP sync surface (mailbox/flag/message sync, snippet/heuristics text
@@ -14,198 +18,530 @@ import {
14
18
  type AccountCredentialsDeps,
15
19
  resolveConnectionCredentials,
16
20
  } from "@remit/mailbox-service/account-credentials";
17
- import { createSecretsService } from "@remit/secrets-service";
18
- import { createKmsDataKeyProvider } from "@remit/secrets-service/kms";
19
- import { sendMail } from "@remit/smtp-service";
20
- import { createStandardQueueProducer } from "@remit/sqs-client/producer";
21
- import { createStorageService } from "@remit/storage-service/s3";
22
- import { env } from "expect-env";
23
- import { buildDataPortsFromEnv, type SmtpDataPorts } from "../data-ports.js";
24
- import type { SendMessageEvent } from "../events.js";
25
21
  import {
26
- createAttachmentLoader,
27
- createAttachmentReader,
28
- } from "./attachment-storage.js";
29
- import { sendMessage } from "./send-message-core.js";
30
-
31
- // The data ports are resolved lazily and cached, not at module load: the
32
- // DynamoDB backend is injected by the composition root before the first send,
33
- // so building at import time would race the registration.
34
- let portsPromise: Promise<SmtpDataPorts> | null = null;
35
- const getPorts = (): Promise<SmtpDataPorts> => {
36
- if (!portsPromise) portsPromise = buildDataPortsFromEnv();
37
- return portsPromise;
38
- };
39
-
40
- const loadAttachments = createAttachmentLoader(
41
- createAttachmentReader(process.env, createStorageService),
42
- async () => (await getPorts()).outboxAttachment,
43
- );
44
-
45
- const dataKeyProvider = createKmsDataKeyProvider(env.KMS_KEY_ID);
46
- const secrets = createSecretsService(dataKeyProvider);
47
-
48
- // Lazy OAuth service (only instantiated when an OAuth account sends mail)
49
- let _tokenService: ReturnType<typeof createMailOAuthService> | undefined;
50
- const getTokenService = () => {
51
- if (!_tokenService) {
52
- _tokenService = createMailOAuthService(
53
- microsoftProviderConfig({
54
- clientId: process.env.MSOAUTH_CLIENT_ID ?? "",
55
- clientSecret: process.env.MSOAUTH_CLIENT_SECRET ?? "",
56
- overrides: process.env.MSOAUTH_TOKEN_ENDPOINT
57
- ? { tokenEndpoint: process.env.MSOAUTH_TOKEN_ENDPOINT }
58
- : undefined,
59
- }),
60
- );
61
- }
62
- return _tokenService;
63
- };
22
+ loadOutboxAttachmentContents,
23
+ OutboxAttachmentUnavailableError,
24
+ } from "@remit/mailbox-service/outbox-attachment-content";
25
+ import type { SecretsService } from "@remit/secrets-service";
26
+ import {
27
+ buildMailMessage,
28
+ type SendResult,
29
+ SmtpConnectionError,
30
+ type SmtpGetSocket,
31
+ sendMail,
32
+ } from "@remit/smtp-service";
33
+ import type { AppendSentMessageEvent, SendMessageEvent } from "../events.js";
34
+ import type { SmtpDataPorts } from "../ports.js";
35
+ import type { AttachmentReader } from "./attachment-storage.js";
36
+ import {
37
+ type EngagementCounterDeps,
38
+ writeEngagementCounters,
39
+ } from "./engagement-counters.js";
40
+ import {
41
+ resolveSmtpConfig,
42
+ type SmtpCredentials,
43
+ } from "./resolve-smtp-config.js";
64
44
 
65
- // APPEND_SENT_MESSAGE is processed by the IMAP worker via the
66
- // message-management queue (see remit-imap-worker/src/emit.ts).
67
- interface AppendSentMessageEvent {
68
- type: "APPEND_SENT_MESSAGE";
69
- accountId: string;
70
- outboxMessageId: string;
71
- eventId: string;
72
- timestamp: number;
45
+ export interface SendMessageContext {
46
+ ports: SmtpDataPorts;
47
+ secrets: SecretsService;
48
+ storage: AttachmentReader;
49
+ messageManagement: QueueProducer<AppendSentMessageEvent>;
50
+ tokenService: Pick<MailOAuthService, "getAccessToken">;
51
+ getSocket: SmtpGetSocket;
52
+ maxAttempts: number;
53
+ recordSmtpFailure: (kind: MailFailureKind) => void;
73
54
  }
74
55
 
75
- const messageMgmtQueue = createStandardQueueProducer<AppendSentMessageEvent>({
76
- queueUrl: env.SQS_QUEUE_URL_MESSAGE_MGMT,
56
+ const log = createLogger();
57
+
58
+ const credentialDeps = (ctx: SendMessageContext): AccountCredentialsDeps => ({
59
+ secrets: ctx.secrets,
60
+ tokenService: ctx.tokenService,
61
+ persistRotatedToken: async (accountId, encryptedHash, updatedAt) => {
62
+ await ctx.ports.account.update(accountId, {
63
+ oauthRefreshTokenHash: encryptedHash,
64
+ oauthTokenUpdatedAt: updatedAt,
65
+ });
66
+ },
77
67
  });
78
68
 
79
- const emitAppendSentMessage = async (
69
+ /**
70
+ * Persist the account's connectionState, and with it the sentence the account
71
+ * card shows. `lastError` carries the server's own words when the refusal
72
+ * came with any; a flip with no reason of its own removes the stored one, so
73
+ * the card never reads back a failure the account has moved on from.
74
+ */
75
+ const updateConnectionState = async (
76
+ ports: SmtpDataPorts,
80
77
  accountId: string,
81
- outboxMessageId: string,
78
+ state: (typeof ConnectionState)[keyof typeof ConnectionState],
79
+ lastError?: string,
82
80
  ): Promise<void> => {
83
- const event: AppendSentMessageEvent = {
84
- type: "APPEND_SENT_MESSAGE",
81
+ await ports.account.update(
85
82
  accountId,
86
- outboxMessageId,
87
- eventId: randomUUID(),
88
- timestamp: Date.now(),
89
- };
90
-
91
- await messageMgmtQueue.send({ body: event });
83
+ { connectionState: state, lastError },
84
+ lastError === undefined ? ["lastError"] : undefined,
85
+ );
92
86
  };
93
87
 
94
- const findMessageByHeader = async (
88
+ const isNotFound = (error: unknown): boolean =>
89
+ typeof error === "object" &&
90
+ error !== null &&
91
+ "name" in error &&
92
+ error.name === "NotFoundError";
93
+
94
+ const engagementDeps = (ports: SmtpDataPorts): EngagementCounterDeps => ({
95
+ resolveAddressId: deriveAddressId,
96
+ incrementOutboundCount: (accountConfigId, addressId, now) =>
97
+ ports.address.incrementOutboundCount(accountConfigId, addressId, now),
98
+ incrementReplyCount: (accountConfigId, addressId, now) =>
99
+ ports.address.incrementReplyCount(accountConfigId, addressId, now),
100
+ findMessageByHeader: (accountId, messageIdHeader) =>
101
+ ports.message
102
+ .get(deriveMessageId(accountId, messageIdHeader))
103
+ .catch((error: unknown) => {
104
+ if (isNotFound(error)) return null;
105
+ throw error;
106
+ }),
107
+ getEnvelopeFromEmail: async (messageId) => {
108
+ const data = await ports.envelope
109
+ .getMessageData(messageId)
110
+ .catch((error: unknown) => {
111
+ if (isNotFound(error)) return null;
112
+ throw error;
113
+ });
114
+ if (!data) return null;
115
+ const fromEntry = data.envelopeAddress.find(
116
+ (addr) => addr.addressRole === "from",
117
+ );
118
+ return fromEntry?.normalizedEmail ?? null;
119
+ },
120
+ });
121
+
122
+ const emitAppendSentMessage = (
123
+ ctx: SendMessageContext,
95
124
  accountId: string,
96
- messageIdHeader: string,
97
- ) => {
98
- const messageId = deriveMessageId(accountId, messageIdHeader);
99
- const { message } = await getPorts();
100
- return message.get(messageId).catch((error: unknown) => {
101
- if ((error as { name?: string })?.name === "NotFoundError") return null;
102
- throw error;
125
+ outboxMessageId: string,
126
+ ): Promise<void> =>
127
+ ctx.messageManagement.send({
128
+ body: {
129
+ type: "APPEND_SENT_MESSAGE",
130
+ accountId,
131
+ outboxMessageId,
132
+ eventId: crypto.randomUUID(),
133
+ timestamp: Date.now(),
134
+ },
103
135
  });
104
- };
105
136
 
106
- const getEnvelopeFromEmail = async (
107
- messageId: string,
108
- ): Promise<string | null> => {
109
- const { envelope } = await getPorts();
110
- const data = await envelope
111
- .getMessageData(messageId)
112
- .catch((error: unknown) => {
113
- if ((error as { name?: string })?.name === "NotFoundError") return null;
114
- throw error;
115
- });
116
- if (!data) return null;
117
- const fromEntry = data.envelopeAddress.find(
118
- (addr) => addr.addressRole === "from",
119
- );
120
- return fromEntry?.normalizedEmail ?? null;
121
- };
137
+ const UNFILED_NOT_QUEUED =
138
+ "Sent, but not filed: the copy for the Sent folder could not be queued.";
122
139
 
123
140
  /**
124
- * Build AccountCredentialsDeps for the SMTP send path.
125
- * Uses resolveConnectionCredentials — the single authType branch in the codebase.
126
- * Rotation is persisted via accountService.update so the next IMAP sync picks
127
- * up the new token without needing a separate refresh.
141
+ * Every re-auth branch settles the row with this, because the row is the only
142
+ * place the user learns the send stopped: `blocked` shows its reason and offers
143
+ * no Retry, and a retry cannot succeed until the account is reconnected.
128
144
  */
129
- const buildCredentialDeps = (): AccountCredentialsDeps => ({
130
- secrets,
131
- tokenService: getTokenService(),
132
- persistRotatedToken: async (accountId, encryptedHash, updatedAt) => {
133
- const { account } = await getPorts();
134
- await account.update(accountId, {
135
- oauthRefreshTokenHash: encryptedHash,
136
- oauthTokenUpdatedAt: updatedAt,
137
- });
138
- },
139
- });
145
+ const REAUTH_REQUIRED =
146
+ "This account needs to be reconnected before it can send. Open Settings → Accounts and choose Reconnect, then send this message again.";
147
+
148
+ const UNFILED_CONNECTION_LOST =
149
+ "The connection to the outgoing server dropped during the send, so this message may already have been delivered. Check with the recipient before sending it again.";
150
+
151
+ /**
152
+ * The connection failures that prove nothing was submitted: no session ever
153
+ * opened, so the server holds no copy and `failed` is safe — Retry sends the
154
+ * only copy there is.
155
+ *
156
+ * `ECONNRESET` and `ETIMEDOUT` classify as `network` too and are deliberately
157
+ * absent. Either can land after DATA, with the message already queued on the
158
+ * server, and a `failed` row invites a Retry that delivers it twice.
159
+ */
160
+ const NEVER_SUBMITTED_CODES: ReadonlySet<string> = new Set([
161
+ "ECONNREFUSED",
162
+ "ENOTFOUND",
163
+ "EHOSTUNREACH",
164
+ ]);
165
+
166
+ const errorCode = (cause: unknown): string =>
167
+ cause instanceof Error && "code" in cause && typeof cause.code === "string"
168
+ ? cause.code
169
+ : "";
140
170
 
141
- export const handleSendMessage = (
171
+ /**
172
+ * Whether the message can be re-sent without risking a second copy. An auth
173
+ * rejection is decided before the envelope; a connection failure carries the
174
+ * code that says how far it got. Anything else counts as possibly delivered,
175
+ * which is the answer that cannot produce a duplicate.
176
+ */
177
+ const neverSubmitted = (err: SmtpConnectionError): boolean =>
178
+ err.kind === "auth" || NEVER_SUBMITTED_CODES.has(errorCode(err.cause));
179
+
180
+ const SENDABLE_STATUSES: ReadonlySet<OutboxMessageItem["status"]> = new Set([
181
+ OutboxMessageStatus.draft,
182
+ OutboxMessageStatus.queued,
183
+ OutboxMessageStatus.sending,
184
+ ]);
185
+
186
+ /**
187
+ * Why a message stopped at its files, in words the Outbox can show. A `failed`
188
+ * row can be edited, sent again and discarded (#933), so the sentence names
189
+ * the file and what to do about it.
190
+ */
191
+ const attachmentFailureReason = (error: unknown): string =>
192
+ error instanceof OutboxAttachmentUnavailableError
193
+ ? `${error.message}, so this message was not sent. Remove "${error.filename}", attach it again, and send.`
194
+ : `The files on this message could not be read (${error instanceof Error ? error.message : String(error)}), so it was not sent. Send it again.`;
195
+
196
+ export const handleSendMessage = async (
142
197
  event: SendMessageEvent,
143
- log: Logger,
144
- receiveCount = 1,
198
+ ctx: SendMessageContext,
199
+ receiveCount: number,
145
200
  ): Promise<void> => {
146
- const credentialDeps = buildCredentialDeps();
147
- return sendMessage(
148
- event,
149
- log,
150
- {
151
- getOutbox: async (accountConfigId, id) =>
152
- (await getPorts()).outboxMessage.get(accountConfigId, id),
153
- getAccount: async (id) => (await getPorts()).account.get(id),
154
- updateOutbox: async (accountConfigId, id, patch) =>
155
- (await getPorts()).outboxMessage.update(accountConfigId, id, patch),
156
- updateOutboxStatus: async (accountConfigId, id, status) =>
157
- (await getPorts()).outboxMessage.updateStatus(
158
- accountConfigId,
159
- id,
160
- status,
161
- ),
162
- updateOutboxIfStatus: async (accountConfigId, id, expected, patch) =>
163
- (await getPorts()).outboxMessage.updateIfStatus(
164
- accountConfigId,
165
- id,
166
- expected,
167
- patch,
168
- ),
169
- markOutboxSent: async (accountConfigId, id, fields) =>
170
- (await getPorts()).outboxMessage.markSent(accountConfigId, id, fields),
171
- secrets,
172
- resolveCredentials: (account) =>
173
- resolveConnectionCredentials(account, credentialDeps),
174
- // A flip with no reason of its own removes the stored one, so the card
175
- // never reads back a failure the account has moved on from.
176
- updateConnectionState: async (id, state, lastError) => {
177
- const { account } = await getPorts();
178
- await account.update(
179
- id,
180
- {
181
- connectionState:
182
- state as (typeof ConnectionState)[keyof typeof ConnectionState],
183
- lastError,
184
- },
185
- lastError === undefined ? ["lastError"] : undefined,
201
+ const { outboxMessageId, accountId } = event;
202
+ const { ports } = ctx;
203
+
204
+ log.info({ outboxMessageId, accountId }, "Processing send message event");
205
+
206
+ // Load the account first: its accountConfigId scopes every outbox lookup and
207
+ // update. The worker's tenant comes from the account it loads by accountId,
208
+ // never off the outbox row it fetches.
209
+ const account = await ports.account.get(accountId);
210
+ const { accountConfigId } = account;
211
+
212
+ const outbox = await ports.outboxMessage.get(
213
+ accountConfigId,
214
+ outboxMessageId,
215
+ );
216
+ // The fence against SQS at-least-once redelivery, stated as the states a
217
+ // send may proceed FROM. Every other state has already been on the wire, so
218
+ // naming them one by one is how a state added later silently starts sending
219
+ // twice.
220
+ if (!SENDABLE_STATUSES.has(outbox.status)) {
221
+ log.info(
222
+ { outboxMessageId, status: outbox.status },
223
+ "Message already left the queue, skipping",
224
+ );
225
+ return;
226
+ }
227
+
228
+ // `send` queues a row before it emits, so an event that finds one at `draft`
229
+ // found a row the user pulled back into the composer after the emit. That is
230
+ // not a stalled send and it keeps its own state. Every other settle is
231
+ // conditional on the status the send was decided against, so a row that has
232
+ // moved on since is not overwritten either.
233
+ const settleBlockedOnReauth = async (
234
+ expected: OutboxMessageItem["status"] = outbox.status,
235
+ ): Promise<void> => {
236
+ if (expected === OutboxMessageStatus.draft) return;
237
+ await ports.outboxMessage.updateIfStatus(
238
+ accountConfigId,
239
+ outboxMessageId,
240
+ expected,
241
+ {
242
+ status: OutboxMessageStatus.blocked,
243
+ lastError: REAUTH_REQUIRED,
244
+ },
245
+ );
246
+ };
247
+
248
+ // Tombstone fence: drop events for deleted accounts (#228)
249
+ if (account.deletedAt) {
250
+ log.info(
251
+ { accountId, deletedAt: account.deletedAt },
252
+ "Account deleted, dropping send event",
253
+ );
254
+ return;
255
+ }
256
+
257
+ // Reauth fence: skip accounts that need re-authentication. No SMTP traffic
258
+ // until the user re-auths (mirrors the IMAP reauth/ACK contract, #472).
259
+ if (account.connectionState === "reauth_required") {
260
+ await settleBlockedOnReauth();
261
+ log.info(
262
+ { accountId, connectionState: account.connectionState },
263
+ "Account requires reauth, blocking the message",
264
+ );
265
+ return;
266
+ }
267
+
268
+ // Resolve credentials. On a terminal OAuth auth failure (token revoked),
269
+ // flip the account to reauth_required and ACK — do not retry. Transient /
270
+ // config failures rethrow for SQS retry/backoff.
271
+ let credentials: SmtpCredentials | undefined;
272
+ try {
273
+ const resolution = await resolveConnectionCredentials(
274
+ account,
275
+ credentialDeps(ctx),
276
+ );
277
+ if (resolution.status === "missing") {
278
+ // Terminal, never a retry (issue #1120). The account still sends if it
279
+ // carries an SMTP-specific credential of its own, so this hands
280
+ // resolveSmtpConfig no credential and lets it decide; where there is
281
+ // none either, the send settles `blocked` below.
282
+ log.warn(
283
+ {
284
+ accountId,
285
+ reason: resolution.reason,
286
+ connectionState: resolution.terminalState,
287
+ },
288
+ "Account stores no credential",
289
+ );
290
+ await updateConnectionState(ports, accountId, resolution.terminalState);
291
+ } else {
292
+ credentials = resolution.credentials;
293
+ }
294
+ } catch (err) {
295
+ if (err instanceof RefreshTokenError) {
296
+ if (err.error.kind === "reauth-required") {
297
+ log.warn(
298
+ { accountId, errorKind: err.error.kind, errorCode: err.error.code },
299
+ "OAuth token revoked; marking account reauth_required",
186
300
  );
301
+ await updateConnectionState(ports, accountId, "reauth_required");
302
+ await settleBlockedOnReauth();
303
+ return; // ACK — do not retry
304
+ }
305
+ // transient or config: let-it-crash (SQS retry / DLQ)
306
+ throw err;
307
+ }
308
+ if (err instanceof SmtpConnectionError && err.kind === "auth") {
309
+ // Only OAuth accounts have a re-auth recovery path. For password
310
+ // accounts, rethrow to preserve pre-PR batch-item-failure behaviour.
311
+ if (account.authType !== AccountAuthType.OauthMicrosoft) {
312
+ throw err;
313
+ }
314
+ log.warn(
315
+ { accountId, errorKind: err.kind, error: err.message },
316
+ "SMTP auth rejected; marking account reauth_required",
317
+ );
318
+ await updateConnectionState(
319
+ ports,
320
+ accountId,
321
+ "reauth_required",
322
+ err.message,
323
+ );
324
+ await settleBlockedOnReauth();
325
+ return; // ACK — do not retry
326
+ }
327
+ throw err;
328
+ }
329
+ const resolved = await resolveSmtpConfig(account, ctx.secrets, credentials);
330
+ if (!resolved.ok) {
331
+ // `blocked` is distinct from `failed`: no auto-retry — the user has to
332
+ // reconfigure the account first (issue #192).
333
+ await ports.outboxMessage.update(accountConfigId, outboxMessageId, {
334
+ status: "blocked",
335
+ lastError: resolved.reason,
336
+ });
337
+ log.error({ accountId, reason: resolved.reason }, "SMTP not configured");
338
+ return;
339
+ }
340
+ const smtpConfig = { ...resolved.config, getSocket: ctx.getSocket };
341
+
342
+ // Below the budget a storage hiccup is retried like any other; at it the
343
+ // record would dead-letter with the row at `queued`, which can be neither
344
+ // sent again nor discarded (#845.8), so the row settles `failed` instead.
345
+ const attachments = await loadOutboxAttachmentContents(
346
+ { attachments: ports.outboxAttachment, storage: ctx.storage },
347
+ { accountConfigId, accountId: account.accountId, outboxMessageId },
348
+ Math.floor(Date.now() / 1000),
349
+ ).catch(async (error: unknown) => {
350
+ if (receiveCount < ctx.maxAttempts) throw error;
351
+ log.error(
352
+ { outboxMessageId, receiveCount, error: String(error) },
353
+ "Attachments could not be loaded; settling the row as failed",
354
+ );
355
+ if (outbox.status !== OutboxMessageStatus.draft) {
356
+ await ports.outboxMessage.updateIfStatus(
357
+ accountConfigId,
358
+ outboxMessageId,
359
+ outbox.status,
360
+ {
361
+ status: OutboxMessageStatus.failed,
362
+ lastError: attachmentFailureReason(error),
363
+ },
364
+ );
365
+ }
366
+ return null;
367
+ });
368
+ if (attachments === null) return;
369
+
370
+ await ports.outboxMessage.updateStatus(
371
+ accountConfigId,
372
+ outboxMessageId,
373
+ "sending",
374
+ );
375
+
376
+ const message = buildMailMessage(outbox, attachments);
377
+
378
+ log.info(
379
+ { outboxMessageId, to: outbox.toAddresses, subject: outbox.subject },
380
+ "Sending message via SMTP",
381
+ );
382
+ let result: SendResult;
383
+ try {
384
+ result = await sendMail(smtpConfig, message);
385
+ } catch (err) {
386
+ // A terminal SMTP auth rejection (e.g. expired OAuth token surfaced at
387
+ // connect time) flips the account to reauth_required and ACKs.
388
+ // Only OAuth accounts have a re-auth recovery path.
389
+ if (
390
+ err instanceof SmtpConnectionError &&
391
+ err.kind === "auth" &&
392
+ account.authType === AccountAuthType.OauthMicrosoft
393
+ ) {
394
+ log.warn(
395
+ { accountId, errorKind: err.kind, error: err.message },
396
+ "SMTP auth rejected during send; marking account reauth_required",
397
+ );
398
+ await updateConnectionState(
399
+ ports,
400
+ accountId,
401
+ "reauth_required",
402
+ err.message,
403
+ );
404
+ await settleBlockedOnReauth(OutboxMessageStatus.sending);
405
+ return; // ACK — do not retry
406
+ }
407
+ // A password account's auth failure and every network failure retry on
408
+ // SQS redelivery until the queue's own budget runs out, then settle
409
+ // here instead of dead-lettering with the row stuck at `sending`
410
+ // (issue #951). Where it settles is the double-send question:
411
+ // `neverSubmitted` says the message cannot be on the server, so
412
+ // `failed` offers the Retry the user needs; anything else settles
413
+ // `unfiled`, the state that says a copy may be out there and which
414
+ // Retry is not offered on. Wait-or-reconcile
415
+ // (docs/architecture/imap-mutations.md R2): neither applies — a
416
+ // submission leaves no server-side handle to reconcile against, so the
417
+ // row settles on what the failure itself proves.
418
+ if (err instanceof SmtpConnectionError) {
419
+ if (receiveCount < ctx.maxAttempts) {
420
+ throw err;
421
+ }
422
+ const settled = neverSubmitted(err)
423
+ ? { status: OutboxMessageStatus.failed, lastError: err.message }
424
+ : {
425
+ status: OutboxMessageStatus.unfiled,
426
+ lastError: `${UNFILED_CONNECTION_LOST} (${err.message})`,
427
+ };
428
+ await ports.outboxMessage.update(
429
+ accountConfigId,
430
+ outboxMessageId,
431
+ settled,
432
+ );
433
+ // Terminal and never re-thrown, so the handler-outcome series
434
+ // records this record as a success. Counted here or it is invisible.
435
+ ctx.recordSmtpFailure(err.kind);
436
+ log.error(
437
+ {
438
+ outboxMessageId,
439
+ errorKind: err.kind,
440
+ receiveCount,
441
+ status: settled.status,
442
+ },
443
+ "SMTP send retry exhausted; settling the row",
444
+ );
445
+ return; // ACK — settled terminal, no more retries
446
+ }
447
+ throw err;
448
+ }
449
+
450
+ if (result.success) {
451
+ await ports.outboxMessage.markSent(accountConfigId, outboxMessageId, {
452
+ sentAt: Date.now(),
453
+ smtpMessageId: result.messageId,
454
+ });
455
+ log.info(
456
+ { outboxMessageId, smtpMessageId: result.messageId },
457
+ "Message sent successfully",
458
+ );
459
+
460
+ await writeEngagementCounters(
461
+ outbox,
462
+ { accountConfigId, accountId: account.accountId },
463
+ engagementDeps(ports),
464
+ log,
465
+ ).catch((error: unknown) => {
466
+ log.warn(
467
+ { outboxMessageId, error: String(error) },
468
+ "Failed to write engagement counters (best-effort)",
469
+ );
470
+ });
471
+
472
+ // The filing is what deletes this row, so an enqueue that never lands
473
+ // leaves it at `sent` — a delivered message the Outbox hides and no Sent
474
+ // folder holds. Settle it here instead: the send itself is done, so
475
+ // throwing would only redeliver an event that cannot re-send.
476
+ await emitAppendSentMessage(ctx, accountId, outboxMessageId).catch(
477
+ async (error: unknown) => {
478
+ log.error(
479
+ { outboxMessageId, error: String(error) },
480
+ "Failed to enqueue APPEND_SENT_MESSAGE; settling the row as unfiled",
481
+ );
482
+ await ports.outboxMessage.update(accountConfigId, outboxMessageId, {
483
+ status: OutboxMessageStatus.unfiled,
484
+ lastError: UNFILED_NOT_QUEUED,
485
+ });
187
486
  },
188
- send: sendMail,
189
- loadAttachments,
190
- emitAppendSentMessage,
191
- engagement: {
192
- resolveAddressId: deriveAddressId,
193
- incrementOutboundCount: async (accountConfigId, addressId, now) =>
194
- (await getPorts()).address.incrementOutboundCount(
195
- accountConfigId,
196
- addressId,
197
- now,
198
- ),
199
- incrementReplyCount: async (accountConfigId, addressId, now) =>
200
- (await getPorts()).address.incrementReplyCount(
201
- accountConfigId,
202
- addressId,
203
- now,
204
- ),
205
- findMessageByHeader,
206
- getEnvelopeFromEmail,
487
+ );
488
+ return;
489
+ }
490
+
491
+ if (result.isTransient) {
492
+ // A 4xx from the server is retried on SQS redelivery the same way a
493
+ // connection failure is above; once the queue's own budget runs out
494
+ // this settles the row at `failed` rather than leaving it at `queued`
495
+ // forever once the record dead-letters (issue #951).
496
+ if (receiveCount < ctx.maxAttempts) {
497
+ log.warn(
498
+ {
499
+ outboxMessageId,
500
+ smtpCode: result.smtpCode,
501
+ error: result.error?.message,
502
+ },
503
+ "Transient failure, will retry",
504
+ );
505
+ await ports.outboxMessage.updateStatus(
506
+ accountConfigId,
507
+ outboxMessageId,
508
+ "queued",
509
+ );
510
+ throw new Error(`SMTP transient error: ${result.error?.message}`);
511
+ }
512
+
513
+ await ports.outboxMessage.update(accountConfigId, outboxMessageId, {
514
+ status: "failed",
515
+ lastError: result.error?.message,
516
+ lastSmtpCode: result.smtpCode,
517
+ });
518
+ // Terminal and never re-thrown, so the handler-outcome series records
519
+ // this record as a success. Counted here or it is invisible.
520
+ ctx.recordSmtpFailure("other");
521
+ log.error(
522
+ {
523
+ outboxMessageId,
524
+ smtpCode: result.smtpCode,
525
+ error: result.error?.message,
526
+ receiveCount,
207
527
  },
528
+ "Transient failure retry exhausted; settling as failed",
529
+ );
530
+ return;
531
+ }
532
+
533
+ // Permanent failure - mark as failed, don't throw (no retry)
534
+ await ports.outboxMessage.update(accountConfigId, outboxMessageId, {
535
+ status: "failed",
536
+ lastError: result.error?.message,
537
+ lastSmtpCode: result.smtpCode,
538
+ });
539
+ log.error(
540
+ {
541
+ outboxMessageId,
542
+ smtpCode: result.smtpCode,
543
+ error: result.error?.message,
208
544
  },
209
- receiveCount,
545
+ "Permanent failure",
210
546
  );
211
547
  };