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