@remit/smtp-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/smtp-worker",
3
- "version": "0.0.25",
3
+ "version": "0.0.27",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -27,6 +27,7 @@
27
27
  "@remit/sqs-client": "*",
28
28
  "@remit/secrets-service": "*",
29
29
  "@remit/smtp-service": "*",
30
+ "@remit/storage-service": "*",
30
31
  "@remit/domain-enums": "*",
31
32
  "@remit/mail-oauth-service": "*",
32
33
  "@remit/mailbox-service": "*",
package/src/data-ports.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  IAddressRepository,
4
4
  IEnvelopeRepository,
5
5
  IMessageRepository,
6
+ IOutboxAttachmentRepository,
6
7
  IOutboxMessageRepository,
7
8
  } from "@remit/data-ports";
8
9
 
@@ -13,6 +14,7 @@ import type {
13
14
  export interface SmtpDataPorts {
14
15
  account: IAccountRepository;
15
16
  outboxMessage: IOutboxMessageRepository;
17
+ outboxAttachment: IOutboxAttachmentRepository;
16
18
  address: IAddressRepository;
17
19
  message: IMessageRepository;
18
20
  envelope: IEnvelopeRepository;
@@ -36,6 +38,7 @@ const buildSqliteDataPorts = async (): Promise<SmtpDataPorts> => {
36
38
  DrizzleEnvelopeRepository,
37
39
  DrizzleMessageRepository,
38
40
  messageDataSchema,
41
+ OutboxAttachmentRepo,
39
42
  OutboxMessageRepo,
40
43
  } = await import("@remit/drizzle-service");
41
44
 
@@ -46,6 +49,7 @@ const buildSqliteDataPorts = async (): Promise<SmtpDataPorts> => {
46
49
  return {
47
50
  account: new AccountRepo(db),
48
51
  outboxMessage: new OutboxMessageRepo(db),
52
+ outboxAttachment: new OutboxAttachmentRepo(db),
49
53
  address: new AddressRepo(db),
50
54
  message: new DrizzleMessageRepository(db),
51
55
  envelope: new DrizzleEnvelopeRepository(db),
@@ -0,0 +1,106 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { OutboxAttachmentItem } from "@remit/data-ports";
4
+ import { createMockStorageService } from "@remit/storage-service";
5
+ import {
6
+ createAttachmentLoader,
7
+ createAttachmentReader,
8
+ } from "./attachment-storage.js";
9
+
10
+ describe("createAttachmentReader", () => {
11
+ it("refuses to read on a Lambda with no bucket, naming the missing setting", () => {
12
+ let created = 0;
13
+ const reader = createAttachmentReader(
14
+ { AWS_LAMBDA_FUNCTION_NAME: "smtp-worker" },
15
+ () => {
16
+ created += 1;
17
+ return createMockStorageService();
18
+ },
19
+ );
20
+
21
+ assert.throws(
22
+ () => reader.retrieveOutboxAttachment("cfg", "acc", "ob", "att"),
23
+ /S3_BUCKET_NAME is not set on this worker/,
24
+ );
25
+ assert.equal(created, 0, "no filesystem fallback was built");
26
+ });
27
+
28
+ it("reads through storage built once, on first use", async () => {
29
+ const storage = createMockStorageService();
30
+ await storage.storeOutboxAttachment({
31
+ accountConfigId: "cfg",
32
+ accountId: "acc",
33
+ outboxMessageId: "ob",
34
+ outboxAttachmentId: "att",
35
+ content: Buffer.from("bytes"),
36
+ });
37
+ let created = 0;
38
+ const reader = createAttachmentReader(
39
+ { AWS_LAMBDA_FUNCTION_NAME: "smtp-worker", S3_BUCKET_NAME: "bucket" },
40
+ () => {
41
+ created += 1;
42
+ return storage;
43
+ },
44
+ );
45
+
46
+ assert.equal(
47
+ String(await reader.retrieveOutboxAttachment("cfg", "acc", "ob", "att")),
48
+ "bytes",
49
+ );
50
+ await reader.retrieveOutboxAttachment("cfg", "acc", "ob", "att");
51
+ assert.equal(created, 1);
52
+ });
53
+ });
54
+
55
+ describe("createAttachmentLoader", () => {
56
+ it("reads the draft's stored files for the tenant it is given", async () => {
57
+ const storage = createMockStorageService();
58
+ await storage.storeOutboxAttachment({
59
+ accountConfigId: "cfg",
60
+ accountId: "acc",
61
+ outboxMessageId: "ob",
62
+ outboxAttachmentId: "att",
63
+ content: Buffer.from("%PDF"),
64
+ });
65
+ const row: OutboxAttachmentItem = {
66
+ outboxAttachmentId: "att",
67
+ outboxMessageId: "ob",
68
+ accountId: "acc",
69
+ accountConfigId: "cfg",
70
+ filename: "report.pdf",
71
+ contentType: "application/pdf",
72
+ sizeBytes: 4,
73
+ state: "Stored",
74
+ storageKey: "k",
75
+ reservationExpiresAt: 0,
76
+ createdAt: 0,
77
+ updatedAt: 0,
78
+ };
79
+ const asked: string[][] = [];
80
+ const load = createAttachmentLoader(
81
+ storage,
82
+ async () => ({
83
+ listByOutboxMessage: async (accountConfigId, outboxMessageId) => {
84
+ asked.push([accountConfigId, outboxMessageId]);
85
+ return [row];
86
+ },
87
+ }),
88
+ () => 1_000_000,
89
+ );
90
+
91
+ const files = await load(
92
+ { accountConfigId: "cfg", accountId: "acc" },
93
+ "ob",
94
+ );
95
+
96
+ assert.deepEqual(asked, [["cfg", "ob"]]);
97
+ assert.deepEqual(
98
+ files.map((file) => [
99
+ file.filename,
100
+ file.contentType,
101
+ String(file.content),
102
+ ]),
103
+ [["report.pdf", "application/pdf", "%PDF"]],
104
+ );
105
+ });
106
+ });
@@ -0,0 +1,52 @@
1
+ import type { IOutboxAttachmentRepository } from "@remit/data-ports";
2
+ import { loadOutboxAttachmentContents } from "@remit/mailbox-service/outbox-attachment-content";
3
+ import type { MailAttachment } from "@remit/smtp-service";
4
+ import type { StorageService } from "@remit/storage-service";
5
+ import type { SendTenant } from "./send-message-core.js";
6
+
7
+ export type AttachmentReader = Pick<StorageService, "retrieveOutboxAttachment">;
8
+
9
+ /**
10
+ * Where this worker reads a message's files from, built on the first read.
11
+ *
12
+ * Without a bucket the storage factory falls back to the local filesystem,
13
+ * which a Lambda does not have: every read would come back empty and blame the
14
+ * file. The check runs on the first read rather than at start, so a deployment
15
+ * not yet given the bucket still sends every message that carries no files.
16
+ */
17
+ export const createAttachmentReader = (
18
+ processEnv: NodeJS.ProcessEnv,
19
+ createStorage: () => StorageService,
20
+ ): AttachmentReader => {
21
+ let storage: StorageService | null = null;
22
+ return {
23
+ retrieveOutboxAttachment: (...args) => {
24
+ if (processEnv.AWS_LAMBDA_FUNCTION_NAME && !processEnv.S3_BUCKET_NAME) {
25
+ throw new Error(
26
+ "S3_BUCKET_NAME is not set on this worker, so it has no attachment storage to read from",
27
+ );
28
+ }
29
+ if (!storage) storage = createStorage();
30
+ return storage.retrieveOutboxAttachment(...args);
31
+ },
32
+ };
33
+ };
34
+
35
+ /** The files a message goes out with, read from this worker's ports and storage. */
36
+ export const createAttachmentLoader =
37
+ (
38
+ reader: AttachmentReader,
39
+ getRepository: () => Promise<
40
+ Pick<IOutboxAttachmentRepository, "listByOutboxMessage">
41
+ >,
42
+ now: () => number = Date.now,
43
+ ) =>
44
+ async (
45
+ tenant: SendTenant,
46
+ outboxMessageId: string,
47
+ ): Promise<MailAttachment[]> =>
48
+ loadOutboxAttachmentContents(
49
+ { attachments: await getRepository(), storage: reader },
50
+ { ...tenant, outboxMessageId },
51
+ Math.floor(now() / 1000),
52
+ );
@@ -8,9 +8,11 @@ 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
10
  import type { CredentialResolution } from "@remit/mailbox-service/account-credentials";
11
+ import { OutboxAttachmentUnavailableError } from "@remit/mailbox-service/outbox-attachment-content";
11
12
  import type { SecretsService } from "@remit/secrets-service";
12
13
  import {
13
14
  buildMailMessage,
15
+ type MailAttachment,
14
16
  type SendResult,
15
17
  SmtpConnectionError,
16
18
  type sendMail,
@@ -87,12 +89,23 @@ export interface SendMessageDeps {
87
89
  */
88
90
  resolveCredentials: (account: AccountItem) => Promise<CredentialResolution>;
89
91
  /**
90
- * Persist the account's connectionState. Called when a terminal OAuth/SMTP
91
- * auth failure is detected so the account is fenced off until the user
92
- * re-auths (mirrors the IMAP withOAuthLifecycle contract).
92
+ * Persist the account's connectionState, and with it the sentence the
93
+ * account card shows. Called when a terminal OAuth/SMTP auth failure is
94
+ * detected so the account is fenced off until the user re-auths (mirrors the
95
+ * IMAP withOAuthLifecycle contract). `lastError` carries the server's own
96
+ * words when the refusal came with any, and its absence clears the stored
97
+ * reason rather than leaving an older failure to describe this one.
93
98
  */
94
- updateConnectionState: (accountId: string, state: string) => Promise<void>;
99
+ updateConnectionState: (
100
+ accountId: string,
101
+ state: string,
102
+ lastError?: string,
103
+ ) => Promise<void>;
95
104
  send: typeof sendMail;
105
+ loadAttachments: (
106
+ tenant: SendTenant,
107
+ outboxMessageId: string,
108
+ ) => Promise<MailAttachment[]>;
96
109
  emitAppendSentMessage: (
97
110
  accountId: string,
98
111
  outboxMessageId: string,
@@ -149,6 +162,16 @@ const SENDABLE_STATUSES: ReadonlySet<OutboxMessageItem["status"]> = new Set([
149
162
  OutboxMessageStatus.sending,
150
163
  ]);
151
164
 
165
+ /**
166
+ * Why a message stopped at its files, in words the Outbox can show. A `failed`
167
+ * row can be edited, sent again and discarded (#933), so the sentence names
168
+ * the file and what to do about it.
169
+ */
170
+ const attachmentFailureReason = (error: unknown): string =>
171
+ error instanceof OutboxAttachmentUnavailableError
172
+ ? `${error.message}, so this message was not sent. Remove "${error.filename}", attach it again, and send.`
173
+ : `The files on this message could not be read (${error instanceof Error ? error.message : String(error)}), so it was not sent. Send it again.`;
174
+
152
175
  export const getSendMessageMaxAttempts = (
153
176
  processEnv: NodeJS.ProcessEnv = process.env,
154
177
  ): number => attemptBudget("SEND_MESSAGE_MAX_ATTEMPTS", 3, processEnv);
@@ -268,10 +291,14 @@ export const sendMessage = async (
268
291
  throw err;
269
292
  }
270
293
  log.warn(
271
- { accountId, errorKind: err.kind },
294
+ { accountId, errorKind: err.kind, error: err.message },
272
295
  "SMTP auth rejected; marking account reauth_required",
273
296
  );
274
- await deps.updateConnectionState(accountId, "reauth_required");
297
+ await deps.updateConnectionState(
298
+ accountId,
299
+ "reauth_required",
300
+ err.message,
301
+ );
275
302
  await settleBlockedOnReauth();
276
303
  return; // ACK — do not retry
277
304
  }
@@ -290,9 +317,38 @@ export const sendMessage = async (
290
317
  }
291
318
  const smtpConfig = resolved.config;
292
319
 
320
+ // Below the budget a storage hiccup is retried like any other; at it the
321
+ // record would dead-letter with the row at `queued`, which can be neither
322
+ // sent again nor discarded (#845.8), so the row settles `failed` instead.
323
+ const attachments = await deps
324
+ .loadAttachments(
325
+ { accountConfigId, accountId: account.accountId },
326
+ outboxMessageId,
327
+ )
328
+ .catch(async (error: unknown) => {
329
+ if (receiveCount < SEND_MESSAGE_MAX_ATTEMPTS) throw error;
330
+ log.error(
331
+ { outboxMessageId, receiveCount, error: String(error) },
332
+ "Attachments could not be loaded; settling the row as failed",
333
+ );
334
+ if (outbox.status !== OutboxMessageStatus.draft) {
335
+ await deps.updateOutboxIfStatus(
336
+ accountConfigId,
337
+ outboxMessageId,
338
+ outbox.status,
339
+ {
340
+ status: OutboxMessageStatus.failed,
341
+ lastError: attachmentFailureReason(error),
342
+ },
343
+ );
344
+ }
345
+ return null;
346
+ });
347
+ if (attachments === null) return;
348
+
293
349
  await deps.updateOutboxStatus(accountConfigId, outboxMessageId, "sending");
294
350
 
295
- const message = buildMailMessage(outbox);
351
+ const message = buildMailMessage(outbox, attachments);
296
352
 
297
353
  log.info(
298
354
  { outboxMessageId, to: outbox.toAddresses, subject: outbox.subject },
@@ -311,10 +367,14 @@ export const sendMessage = async (
311
367
  account.authType === AccountAuthType.OauthMicrosoft
312
368
  ) {
313
369
  log.warn(
314
- { accountId, errorKind: err.kind },
370
+ { accountId, errorKind: err.kind, error: err.message },
315
371
  "SMTP auth rejected during send; marking account reauth_required",
316
372
  );
317
- await deps.updateConnectionState(accountId, "reauth_required");
373
+ await deps.updateConnectionState(
374
+ accountId,
375
+ "reauth_required",
376
+ err.message,
377
+ );
318
378
  await settleBlockedOnReauth(OutboxMessageStatus.sending);
319
379
  return; // ACK — do not retry
320
380
  }
@@ -7,11 +7,17 @@ import type {
7
7
  } from "@remit/data-ports";
8
8
  import { AccountAuthType } from "@remit/domain-enums";
9
9
  import { RefreshTokenError } from "@remit/mail-oauth-service";
10
+ import { OutboxAttachmentUnavailableError } from "@remit/mailbox-service/outbox-attachment-content";
10
11
  import {
11
12
  type EncryptedPayload,
12
13
  serializeEncryptedPayload,
13
14
  } from "@remit/secrets-service";
14
- import { type SendResult, SmtpConnectionError } from "@remit/smtp-service";
15
+ import {
16
+ type MailAttachment,
17
+ type MailMessage,
18
+ type SendResult,
19
+ SmtpConnectionError,
20
+ } from "@remit/smtp-service";
15
21
  import type { SendMessageEvent } from "../events.js";
16
22
  import {
17
23
  getSendMessageMaxAttempts,
@@ -104,6 +110,8 @@ const buildDeps = (
104
110
  appendThrows?: Error;
105
111
  resolveCredentials?: SendMessageDeps["resolveCredentials"];
106
112
  send?: SendMessageDeps["send"];
113
+ attachments?: MailAttachment[];
114
+ attachmentsFail?: Error;
107
115
  } = {},
108
116
  ): { deps: SendMessageDeps; recorded: Recorded } => {
109
117
  const recorded: Recorded = {
@@ -194,6 +202,13 @@ const buildDeps = (
194
202
  }
195
203
  );
196
204
  }),
205
+ loadAttachments: async (tenant, outboxMessageId) => {
206
+ assert.equal(tenant.accountConfigId, account.accountConfigId);
207
+ assert.equal(tenant.accountId, account.accountId);
208
+ assert.equal(outboxMessageId, outbox.outboxMessageId);
209
+ if (options.attachmentsFail) throw options.attachmentsFail;
210
+ return options.attachments ?? [];
211
+ },
197
212
  emitAppendSentMessage: async (accountId, outboxMessageId) => {
198
213
  recorded.appendCalls.push({ accountId, outboxMessageId });
199
214
  if (options.appendThrows) throw options.appendThrows;
@@ -225,6 +240,65 @@ const event: SendMessageEvent = {
225
240
  };
226
241
 
227
242
  describe("sendMessage handler", () => {
243
+ it("retries a message whose files could not be read, below the budget", async () => {
244
+ const { deps, recorded } = buildDeps({
245
+ outbox: buildOutbox({ status: "queued" }),
246
+ attachmentsFail: new OutboxAttachmentUnavailableError(
247
+ "payroll.xlsx",
248
+ "nothing is stored at key/payroll",
249
+ ),
250
+ });
251
+
252
+ await assert.rejects(() => sendMessage(event, silentLogger, deps, 1));
253
+
254
+ assert.equal(recorded.sendCalls, 0);
255
+ assert.deepEqual(recorded.updates, []);
256
+ assert.deepEqual(recorded.statuses, [], "never moved to sending");
257
+ });
258
+
259
+ it("settles a message whose files cannot be read as failed, naming the file, at the budget", async () => {
260
+ const { deps, recorded } = buildDeps({
261
+ outbox: buildOutbox({ status: "queued" }),
262
+ attachmentsFail: new OutboxAttachmentUnavailableError(
263
+ "payroll.xlsx",
264
+ "nothing is stored at key/payroll",
265
+ ),
266
+ });
267
+
268
+ await sendMessage(event, silentLogger, deps, SEND_MESSAGE_MAX_ATTEMPTS);
269
+
270
+ assert.equal(recorded.sendCalls, 0);
271
+ assert.equal(recorded.updates.length, 1);
272
+ assert.equal(recorded.updates[0].patch.status, "failed");
273
+ assert.equal(
274
+ recorded.updates[0].patch.lastError,
275
+ '"payroll.xlsx" could not be read: nothing is stored at key/payroll, so this message was not sent. Remove "payroll.xlsx", attach it again, and send.',
276
+ );
277
+ });
278
+
279
+ it("sends every file the draft carries as a part of the message", async () => {
280
+ const attachments: MailAttachment[] = [
281
+ {
282
+ filename: "report.pdf",
283
+ contentType: "application/pdf",
284
+ content: Buffer.from("%PDF-1.4"),
285
+ },
286
+ ];
287
+ const sent: MailMessage[] = [];
288
+ const { deps } = buildDeps({
289
+ attachments,
290
+ send: async (_config, message) => {
291
+ sent.push(message);
292
+ return { success: true, messageId: "smtp-mid-1", isTransient: false };
293
+ },
294
+ });
295
+
296
+ await sendMessage(event, silentLogger, deps);
297
+
298
+ assert.equal(sent.length, 1);
299
+ assert.deepEqual(sent[0].attachments, attachments);
300
+ });
301
+
228
302
  it("marks status `blocked` when SMTP is disabled — never `sent`", async () => {
229
303
  const { deps, recorded } = buildDeps({
230
304
  account: buildAccount({ smtpEnabled: false }),
@@ -21,9 +21,14 @@ import {
21
21
  } from "@remit/secrets-service";
22
22
  import { sendMail } from "@remit/smtp-service";
23
23
  import { createQueueProducer } from "@remit/sqs-client/producer";
24
+ import { createStorageService } from "@remit/storage-service/s3";
24
25
  import { env } from "expect-env";
25
26
  import { buildDataPortsFromEnv, type SmtpDataPorts } from "../data-ports.js";
26
27
  import type { SendMessageEvent } from "../events.js";
28
+ import {
29
+ createAttachmentLoader,
30
+ createAttachmentReader,
31
+ } from "./attachment-storage.js";
27
32
  import { sendMessage } from "./send-message-core.js";
28
33
 
29
34
  // The data ports are resolved lazily and cached, not at module load: the
@@ -35,6 +40,11 @@ const getPorts = (): Promise<SmtpDataPorts> => {
35
40
  return portsPromise;
36
41
  };
37
42
 
43
+ const loadAttachments = createAttachmentLoader(
44
+ createAttachmentReader(process.env, createStorageService),
45
+ async () => (await getPorts()).outboxAttachment,
46
+ );
47
+
38
48
  const dataKeyProvider = createKmsDataKeyProvider(env.KMS_KEY_ID);
39
49
  const secrets = createSecretsService(dataKeyProvider);
40
50
 
@@ -162,14 +172,22 @@ export const handleSendMessage = (
162
172
  secrets,
163
173
  resolveCredentials: (account) =>
164
174
  resolveConnectionCredentials(account, credentialDeps),
165
- updateConnectionState: async (id, state) => {
175
+ // A flip with no reason of its own removes the stored one, so the card
176
+ // never reads back a failure the account has moved on from.
177
+ updateConnectionState: async (id, state, lastError) => {
166
178
  const { account } = await getPorts();
167
- await account.update(id, {
168
- connectionState:
169
- state as (typeof ConnectionState)[keyof typeof ConnectionState],
170
- });
179
+ await account.update(
180
+ id,
181
+ {
182
+ connectionState:
183
+ state as (typeof ConnectionState)[keyof typeof ConnectionState],
184
+ lastError,
185
+ },
186
+ lastError === undefined ? ["lastError"] : undefined,
187
+ );
171
188
  },
172
189
  send: sendMail,
190
+ loadAttachments,
173
191
  emitAppendSentMessage,
174
192
  engagement: {
175
193
  resolveAddressId: deriveAddressId,