@remit/mailbox-service 0.0.57 → 0.0.58

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/mailbox-service",
3
- "version": "0.0.57",
3
+ "version": "0.0.58",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -0,0 +1,157 @@
1
+ /**
2
+ * A message with nobody to send to must be refused where the person who pressed
3
+ * Send can see the refusal.
4
+ *
5
+ * `@minItems(1)` on `CreateOutboxMessageInput.toAddresses` was the only check
6
+ * anywhere on the path, and neither route into the queue passes it: `send`
7
+ * takes a stored draft, which the update endpoint will happily strip every
8
+ * address off, and `createAndSend` reaches nodemailer with whatever it was
9
+ * handed. Nodemailer refuses an empty envelope inside the SMTP worker, so the
10
+ * message dies in the DLQ and the composer reports a send that went nowhere.
11
+ */
12
+
13
+ import assert from "node:assert/strict";
14
+ import { describe, it } from "node:test";
15
+ import type {
16
+ IAccountRepository,
17
+ IOutboxMessageRepository,
18
+ OutboxMessageItem,
19
+ } from "@remit/data-ports";
20
+ import { BadRequestError } from "@remit/data-ports/errors";
21
+ import { OutboxMessageStatus } from "@remit/domain-enums";
22
+ import type { OutboxAttachmentService } from "./outbox-attachment.js";
23
+ import { OutboxQueueService } from "./outbox-queue.js";
24
+
25
+ const ACCOUNT_CONFIG_ID = "cfg-1";
26
+ const ACCOUNT_ID = "acc-1";
27
+ const OUTBOX_MESSAGE_ID = "ob-1";
28
+
29
+ const draft = (overrides: Partial<OutboxMessageItem>): OutboxMessageItem =>
30
+ ({
31
+ outboxMessageId: OUTBOX_MESSAGE_ID,
32
+ accountId: ACCOUNT_ID,
33
+ accountConfigId: ACCOUNT_CONFIG_ID,
34
+ fromAddress: "me@example.com",
35
+ toAddresses: ["them@example.com"],
36
+ ccAddresses: [],
37
+ bccAddresses: [],
38
+ references: [],
39
+ messageIdValue: "<m1@example.com>",
40
+ status: OutboxMessageStatus.draft,
41
+ createdAt: 0,
42
+ updatedAt: 0,
43
+ ...overrides,
44
+ }) as OutboxMessageItem;
45
+
46
+ interface Harness {
47
+ service: OutboxQueueService;
48
+ enqueued: string[];
49
+ created: number;
50
+ statusWrites: string[];
51
+ }
52
+
53
+ const createHarness = (stored: OutboxMessageItem): Harness => {
54
+ const harness: Harness = {
55
+ service: undefined as unknown as OutboxQueueService,
56
+ enqueued: [],
57
+ created: 0,
58
+ statusWrites: [],
59
+ };
60
+
61
+ const outboxMessageService = {
62
+ get: async () => stored,
63
+ create: async (input: Record<string, unknown>) => {
64
+ harness.created += 1;
65
+ return draft(input as Partial<OutboxMessageItem>);
66
+ },
67
+ updateStatus: async (
68
+ _configId: string,
69
+ _id: string,
70
+ status: OutboxMessageItem["status"],
71
+ ) => {
72
+ harness.statusWrites.push(status);
73
+ return draft({ status });
74
+ },
75
+ } as unknown as IOutboxMessageRepository;
76
+
77
+ harness.service = new OutboxQueueService({
78
+ outboxMessageService,
79
+ outboxAttachmentService: {} as unknown as OutboxAttachmentService,
80
+ accountService: {} as unknown as IAccountRepository,
81
+ sqsSmtpQueueUrl: "http://localhost/queue",
82
+ sqsClient: {
83
+ send: async (command: { input: { MessageBody: string } }) => {
84
+ harness.enqueued.push(command.input.MessageBody);
85
+ return {};
86
+ },
87
+ } as never,
88
+ });
89
+
90
+ return harness;
91
+ };
92
+
93
+ const sendInput = (overrides: Record<string, unknown>) => ({
94
+ accountId: ACCOUNT_ID,
95
+ accountConfigId: ACCOUNT_CONFIG_ID,
96
+ fromAddress: "me@example.com",
97
+ toAddresses: [] as string[],
98
+ ...overrides,
99
+ });
100
+
101
+ describe("OutboxQueueService and a message with nowhere to go", () => {
102
+ it("refuses to queue a stored draft that has lost every address", async () => {
103
+ const harness = createHarness(
104
+ draft({ toAddresses: [], ccAddresses: [], bccAddresses: [] }),
105
+ );
106
+
107
+ await assert.rejects(
108
+ () => harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
109
+ (error: unknown) => {
110
+ assert.ok(error instanceof BadRequestError);
111
+ assert.equal(error.statusCode, 400);
112
+ return true;
113
+ },
114
+ );
115
+
116
+ assert.deepEqual(harness.statusWrites, [], "it stayed a draft");
117
+ assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
118
+ });
119
+
120
+ it("queues a draft addressed only in Bcc — that envelope is real", async () => {
121
+ const harness = createHarness(
122
+ draft({ toAddresses: [], bccAddresses: ["them@example.com"] }),
123
+ );
124
+
125
+ await harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID);
126
+
127
+ assert.deepEqual(harness.statusWrites, [OutboxMessageStatus.queued]);
128
+ assert.equal(harness.enqueued.length, 1);
129
+ });
130
+
131
+ it("refuses a send-immediately create with no addresses, before writing a row", async () => {
132
+ const harness = createHarness(draft({}));
133
+
134
+ await assert.rejects(
135
+ () => harness.service.createAndSend(sendInput({})),
136
+ (error: unknown) => {
137
+ assert.ok(error instanceof BadRequestError);
138
+ assert.equal(error.statusCode, 400);
139
+ return true;
140
+ },
141
+ );
142
+
143
+ assert.equal(harness.created, 0, "no queued row was left behind");
144
+ assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
145
+ });
146
+
147
+ it("still creates and queues a send-immediately message that has a recipient", async () => {
148
+ const harness = createHarness(draft({}));
149
+
150
+ await harness.service.createAndSend(
151
+ sendInput({ toAddresses: ["them@example.com"] }),
152
+ );
153
+
154
+ assert.equal(harness.created, 1);
155
+ assert.equal(harness.enqueued.length, 1);
156
+ });
157
+ });
@@ -5,7 +5,7 @@ import type {
5
5
  IOutboxMessageRepository,
6
6
  OutboxMessageItem,
7
7
  } from "@remit/data-ports";
8
- import { ConflictError } from "@remit/data-ports/errors";
8
+ import { BadRequestError, ConflictError } from "@remit/data-ports/errors";
9
9
  import { OutboxMessageStatus } from "@remit/domain-enums";
10
10
  import { createQueueProducer } from "@remit/sqs-client/producer";
11
11
  import type { OutboxAttachmentService } from "./outbox-attachment.js";
@@ -64,6 +64,29 @@ export interface UpdateDraftInput {
64
64
  references?: string[];
65
65
  }
66
66
 
67
+ /**
68
+ * Nobody to send to. `@minItems(1)` on the create input is the only thing that
69
+ * has ever stood between a zero-recipient message and nodemailer, which refuses
70
+ * an empty envelope — and refuses it inside the SMTP worker, where the failure
71
+ * lands in the DLQ rather than in front of the person who pressed Send. Neither
72
+ * route into the queue passes that schema: a draft is created with a recipient
73
+ * and can be edited down to none, and `send` never sees the create input at all.
74
+ *
75
+ * Cc and Bcc count. A message addressed only in Bcc is a real message with a
76
+ * real envelope; only a message with no address anywhere has nowhere to go.
77
+ */
78
+ const hasNowhereToGo = (message: {
79
+ toAddresses?: string[];
80
+ ccAddresses?: string[];
81
+ bccAddresses?: string[];
82
+ }): boolean =>
83
+ (message.toAddresses?.length ?? 0) === 0 &&
84
+ (message.ccAddresses?.length ?? 0) === 0 &&
85
+ (message.bccAddresses?.length ?? 0) === 0;
86
+
87
+ const NO_RECIPIENT_MESSAGE =
88
+ "This message has nobody to send to. Add a recipient before sending it.";
89
+
67
90
  const generateMessageId = (domain: string): string => {
68
91
  const timestamp = Date.now();
69
92
  const random = randomUUID().replace(/-/g, "").slice(0, 16);
@@ -198,6 +221,10 @@ export class OutboxQueueService {
198
221
  );
199
222
  }
200
223
 
224
+ if (hasNowhereToGo(existing)) {
225
+ throw new BadRequestError(NO_RECIPIENT_MESSAGE);
226
+ }
227
+
201
228
  const updated = await this.outboxMessageService.updateStatus(
202
229
  accountConfigId,
203
230
  outboxMessageId,
@@ -217,6 +244,10 @@ export class OutboxQueueService {
217
244
  createAndSend = async (
218
245
  input: CreateDraftInput,
219
246
  ): Promise<OutboxMessageItem> => {
247
+ if (hasNowhereToGo(input)) {
248
+ throw new BadRequestError(NO_RECIPIENT_MESSAGE);
249
+ }
250
+
220
251
  const domain = extractDomain(input.fromAddress);
221
252
  const messageIdValue = generateMessageId(domain);
222
253