@remit/mailbox-service 0.0.73 → 0.0.74
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
package/src/outbox-attachment.ts
CHANGED
|
@@ -7,10 +7,7 @@ import type {
|
|
|
7
7
|
import { holdsRoom } from "@remit/data-ports";
|
|
8
8
|
import { ConflictError } from "@remit/data-ports/errors";
|
|
9
9
|
import { base36uuid } from "@remit/data-ports/id";
|
|
10
|
-
import {
|
|
11
|
-
OutboxAttachmentRejectionReason,
|
|
12
|
-
OutboxMessageStatus,
|
|
13
|
-
} from "@remit/domain-enums";
|
|
10
|
+
import { OutboxAttachmentRejectionReason } from "@remit/domain-enums";
|
|
14
11
|
import type { StorageService } from "@remit/storage-service";
|
|
15
12
|
import {
|
|
16
13
|
buildOutboxAttachmentKey,
|
|
@@ -20,6 +17,7 @@ import {
|
|
|
20
17
|
normalizeAttachmentContentType,
|
|
21
18
|
sanitizeAttachmentFilename,
|
|
22
19
|
} from "./outbox-attachment-filename.js";
|
|
20
|
+
import { isOpenForWork } from "./outbox-status.js";
|
|
23
21
|
|
|
24
22
|
/**
|
|
25
23
|
* 25 MB is what most receiving servers accept, and base64 inflates it to roughly
|
|
@@ -111,9 +109,14 @@ export class OutboxAttachmentService {
|
|
|
111
109
|
* Resolve a draft the caller is entitled to act on.
|
|
112
110
|
*
|
|
113
111
|
* Mode "act": the caller has named the draft, so a foreign one is denied with
|
|
114
|
-
* 403 rather than feigned as a 404. An entry
|
|
115
|
-
* conflict. Both abort the request — only the file itself comes back as
|
|
116
|
-
* result the composer can render next to the row it refused.
|
|
112
|
+
* 403 rather than feigned as a 404. An entry the user can no longer work on
|
|
113
|
+
* is a conflict. Both abort the request — only the file itself comes back as
|
|
114
|
+
* a result the composer can render next to the row it refused.
|
|
115
|
+
*
|
|
116
|
+
* Same predicate the composer's other two writes use. A `failed` message is
|
|
117
|
+
* editable and sendable again (#933), and an attachment is part of what the
|
|
118
|
+
* correction may be — a message refused for the wrong file would otherwise
|
|
119
|
+
* still be stuck.
|
|
117
120
|
*/
|
|
118
121
|
private getWritableDraft = async (
|
|
119
122
|
accountConfigId: string,
|
|
@@ -125,7 +128,7 @@ export class OutboxAttachmentService {
|
|
|
125
128
|
"act",
|
|
126
129
|
);
|
|
127
130
|
|
|
128
|
-
if (outbox.status
|
|
131
|
+
if (!isOpenForWork(outbox.status)) {
|
|
129
132
|
throw new ConflictError(
|
|
130
133
|
`This message is already ${outbox.status} and can no longer take an attachment. Start a new message to change it.`,
|
|
131
134
|
);
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two ways an outbox row used to become unusable without ever leaving.
|
|
3
|
+
*
|
|
4
|
+
* `createAndSend` wrote the row at `queued` and then enqueued. A throw from the
|
|
5
|
+
* enqueue left it there, and `queued` is accepted by neither `send` nor
|
|
6
|
+
* `deleteDraft` — the message could be neither sent nor discarded (#936).
|
|
7
|
+
*
|
|
8
|
+
* A send that failed settled at `failed`, and `updateDraft` refused anything
|
|
9
|
+
* but `draft`. Retry re-queued the same envelope and Edit took a 409 on the
|
|
10
|
+
* flush that precedes the send, so a message refused for a bad address had no
|
|
11
|
+
* way back to sent short of retyping it (#933).
|
|
12
|
+
*
|
|
13
|
+
* Where a stranded row settles is the load-bearing part. Not `draft`: a throw
|
|
14
|
+
* from `SendMessage` says the response was lost, not that the broker refused
|
|
15
|
+
* the event, and `draft` is inside the SMTP worker's send fence — a row put
|
|
16
|
+
* there is sendable both by the event that landed anyway and by the user
|
|
17
|
+
* pressing Send. `failed` is outside that fence and, since #933, is a row the
|
|
18
|
+
* user can still edit and send.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import { describe, it } from "node:test";
|
|
23
|
+
import type {
|
|
24
|
+
IAccountRepository,
|
|
25
|
+
IOutboxMessageRepository,
|
|
26
|
+
OutboxMessageItem,
|
|
27
|
+
UpdateOutboxMessageInput,
|
|
28
|
+
} from "@remit/data-ports";
|
|
29
|
+
import { ConflictError } from "@remit/data-ports/errors";
|
|
30
|
+
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
31
|
+
import type { OutboxAttachmentService } from "./outbox-attachment.js";
|
|
32
|
+
import { OutboxQueueService } from "./outbox-queue.js";
|
|
33
|
+
|
|
34
|
+
const ACCOUNT_CONFIG_ID = "cfg-1";
|
|
35
|
+
const ACCOUNT_ID = "acc-1";
|
|
36
|
+
const OUTBOX_MESSAGE_ID = "ob-1";
|
|
37
|
+
|
|
38
|
+
const row = (overrides: Partial<OutboxMessageItem>): OutboxMessageItem =>
|
|
39
|
+
({
|
|
40
|
+
outboxMessageId: OUTBOX_MESSAGE_ID,
|
|
41
|
+
accountId: ACCOUNT_ID,
|
|
42
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
43
|
+
fromAddress: "me@example.com",
|
|
44
|
+
toAddresses: ["typo@exmaple.com"],
|
|
45
|
+
ccAddresses: [],
|
|
46
|
+
bccAddresses: [],
|
|
47
|
+
references: [],
|
|
48
|
+
messageIdValue: "<m1@example.com>",
|
|
49
|
+
subject: "Invoice",
|
|
50
|
+
textBody: "Attached.",
|
|
51
|
+
status: OutboxMessageStatus.draft,
|
|
52
|
+
createdAt: 0,
|
|
53
|
+
updatedAt: 0,
|
|
54
|
+
...overrides,
|
|
55
|
+
}) as OutboxMessageItem;
|
|
56
|
+
|
|
57
|
+
interface ConditionalWrite {
|
|
58
|
+
expected: OutboxMessageItem["status"];
|
|
59
|
+
input: UpdateOutboxMessageInput;
|
|
60
|
+
applied: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Harness {
|
|
64
|
+
service: OutboxQueueService;
|
|
65
|
+
enqueued: string[];
|
|
66
|
+
createdStatuses: string[];
|
|
67
|
+
writes: ConditionalWrite[];
|
|
68
|
+
/** What the row holds now, after every write this harness applied. */
|
|
69
|
+
status: () => OutboxMessageItem["status"];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface HarnessOptions {
|
|
73
|
+
enqueueFails?: Error;
|
|
74
|
+
settleFails?: Error;
|
|
75
|
+
/** Moves the row the instant the service reads it, as a racing writer would. */
|
|
76
|
+
movesOnRead?: OutboxMessageItem["status"];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const createHarness = (
|
|
80
|
+
stored: OutboxMessageItem,
|
|
81
|
+
options: HarnessOptions = {},
|
|
82
|
+
): Harness => {
|
|
83
|
+
let current = stored;
|
|
84
|
+
|
|
85
|
+
const harness: Harness = {
|
|
86
|
+
service: undefined as unknown as OutboxQueueService,
|
|
87
|
+
enqueued: [],
|
|
88
|
+
createdStatuses: [],
|
|
89
|
+
writes: [],
|
|
90
|
+
status: () => current.status,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const outboxMessageService = {
|
|
94
|
+
get: async () => {
|
|
95
|
+
const seen = current;
|
|
96
|
+
if (options.movesOnRead) {
|
|
97
|
+
current = row({ ...current, status: options.movesOnRead });
|
|
98
|
+
}
|
|
99
|
+
return seen;
|
|
100
|
+
},
|
|
101
|
+
create: async (input: Record<string, unknown>) => {
|
|
102
|
+
current = row(input as Partial<OutboxMessageItem>);
|
|
103
|
+
harness.createdStatuses.push(current.status);
|
|
104
|
+
return current;
|
|
105
|
+
},
|
|
106
|
+
updateIfStatus: async (
|
|
107
|
+
_configId: string,
|
|
108
|
+
_id: string,
|
|
109
|
+
expected: OutboxMessageItem["status"],
|
|
110
|
+
input: UpdateOutboxMessageInput,
|
|
111
|
+
) => {
|
|
112
|
+
if (options.settleFails && input.status === OutboxMessageStatus.failed) {
|
|
113
|
+
throw options.settleFails;
|
|
114
|
+
}
|
|
115
|
+
const applied = current.status === expected;
|
|
116
|
+
harness.writes.push({ expected, input, applied });
|
|
117
|
+
if (!applied) return null;
|
|
118
|
+
current = row({ ...current, ...input });
|
|
119
|
+
return current;
|
|
120
|
+
},
|
|
121
|
+
} as unknown as IOutboxMessageRepository;
|
|
122
|
+
|
|
123
|
+
harness.service = new OutboxQueueService({
|
|
124
|
+
outboxMessageService,
|
|
125
|
+
outboxAttachmentService: {} as unknown as OutboxAttachmentService,
|
|
126
|
+
accountService: {} as unknown as IAccountRepository,
|
|
127
|
+
sqsSmtpQueueUrl: "http://localhost/queue",
|
|
128
|
+
sqsClient: {
|
|
129
|
+
send: async (command: { input: { MessageBody: string } }) => {
|
|
130
|
+
if (options.enqueueFails) throw options.enqueueFails;
|
|
131
|
+
harness.enqueued.push(command.input.MessageBody);
|
|
132
|
+
return {};
|
|
133
|
+
},
|
|
134
|
+
} as never,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return harness;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const createInput = {
|
|
141
|
+
accountId: ACCOUNT_ID,
|
|
142
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
143
|
+
fromAddress: "me@example.com",
|
|
144
|
+
toAddresses: ["them@example.com"],
|
|
145
|
+
subject: "Invoice",
|
|
146
|
+
textBody: "Attached.",
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const applied = (harness: Harness): UpdateOutboxMessageInput[] =>
|
|
150
|
+
harness.writes.filter((write) => write.applied).map((write) => write.input);
|
|
151
|
+
|
|
152
|
+
describe("an enqueue that reports a failure", () => {
|
|
153
|
+
it("settles the new row of createAndSend at `failed`, never at `queued`", async () => {
|
|
154
|
+
const harness = createHarness(row({}), {
|
|
155
|
+
enqueueFails: new Error("SQS unavailable"),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await assert.rejects(
|
|
159
|
+
() => harness.service.createAndSend(createInput),
|
|
160
|
+
/SQS unavailable/,
|
|
161
|
+
"the caller still hears the failure",
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
assert.deepEqual(harness.createdStatuses, [OutboxMessageStatus.queued]);
|
|
165
|
+
assert.equal(harness.status(), OutboxMessageStatus.failed);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("settles at `failed` rather than `draft` — the event may have landed", async () => {
|
|
169
|
+
// `draft` is inside the worker's send fence, so a row put back there is
|
|
170
|
+
// sent by the landed event and sendable again by hand: two copies of one
|
|
171
|
+
// message. This is the assertion that keeps it out.
|
|
172
|
+
const harness = createHarness(row({}), {
|
|
173
|
+
enqueueFails: new Error("SQS unavailable"),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await assert.rejects(() => harness.service.createAndSend(createInput));
|
|
177
|
+
|
|
178
|
+
assert.notEqual(harness.status(), OutboxMessageStatus.draft);
|
|
179
|
+
assert.match(
|
|
180
|
+
String(applied(harness).at(-1)?.lastError),
|
|
181
|
+
/could not be handed to the outgoing queue/,
|
|
182
|
+
"and it says why, where the Outbox shows it",
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("takes a draft that was sent to `failed`, not back to `draft`", async () => {
|
|
187
|
+
const harness = createHarness(row({}), {
|
|
188
|
+
enqueueFails: new Error("SQS unavailable"),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await assert.rejects(() =>
|
|
192
|
+
harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
assert.equal(harness.status(), OutboxMessageStatus.failed);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("returns a `blocked` row to `blocked` — outside the fence, and the reason stands", async () => {
|
|
199
|
+
const harness = createHarness(
|
|
200
|
+
row({ status: OutboxMessageStatus.blocked }),
|
|
201
|
+
{
|
|
202
|
+
enqueueFails: new Error("SQS unavailable"),
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
await assert.rejects(() =>
|
|
207
|
+
harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
assert.equal(harness.status(), OutboxMessageStatus.blocked);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("only settles a row still at `queued` — the worker may already hold it", async () => {
|
|
214
|
+
const harness = createHarness(row({}), {
|
|
215
|
+
enqueueFails: new Error("SQS unavailable"),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
await assert.rejects(() => harness.service.createAndSend(createInput));
|
|
219
|
+
|
|
220
|
+
const settle = harness.writes.at(-1);
|
|
221
|
+
assert.equal(settle?.expected, OutboxMessageStatus.queued);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("still reports the enqueue failure when the settle write also fails", async () => {
|
|
225
|
+
// The settle is the repair, not the failure. Replacing the enqueue error
|
|
226
|
+
// with the repair's would name the wrong cause and hide the row's state.
|
|
227
|
+
const harness = createHarness(row({}), {
|
|
228
|
+
enqueueFails: new Error("SQS unavailable"),
|
|
229
|
+
settleFails: new Error("database unreachable"),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await assert.rejects(
|
|
233
|
+
() => harness.service.createAndSend(createInput),
|
|
234
|
+
/SQS unavailable/,
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("keeps the row at `queued` when the enqueue succeeds", async () => {
|
|
239
|
+
const harness = createHarness(row({}));
|
|
240
|
+
|
|
241
|
+
await harness.service.createAndSend(createInput);
|
|
242
|
+
|
|
243
|
+
assert.equal(harness.enqueued.length, 1);
|
|
244
|
+
assert.deepEqual(harness.writes, [], "nothing walked the row back");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("correcting a message that failed to send", () => {
|
|
249
|
+
it("accepts the edit and returns the row to `draft`", async () => {
|
|
250
|
+
const harness = createHarness(row({ status: OutboxMessageStatus.failed }));
|
|
251
|
+
|
|
252
|
+
const updated = await harness.service.updateDraft(
|
|
253
|
+
ACCOUNT_CONFIG_ID,
|
|
254
|
+
OUTBOX_MESSAGE_ID,
|
|
255
|
+
{ toAddresses: ["them@example.com"] },
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
assert.deepEqual(applied(harness), [
|
|
259
|
+
{
|
|
260
|
+
status: OutboxMessageStatus.draft,
|
|
261
|
+
toAddresses: ["them@example.com"],
|
|
262
|
+
},
|
|
263
|
+
]);
|
|
264
|
+
assert.equal(updated.status, OutboxMessageStatus.draft);
|
|
265
|
+
assert.equal(updated.subject, "Invoice", "its content came with it");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("sends the corrected message", async () => {
|
|
269
|
+
const harness = createHarness(row({ status: OutboxMessageStatus.failed }));
|
|
270
|
+
|
|
271
|
+
await harness.service.updateDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID, {
|
|
272
|
+
toAddresses: ["them@example.com"],
|
|
273
|
+
});
|
|
274
|
+
await harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID);
|
|
275
|
+
|
|
276
|
+
assert.equal(harness.status(), OutboxMessageStatus.queued);
|
|
277
|
+
assert.equal(harness.enqueued.length, 1);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("accepts the edit on a `blocked` row too — Send already does", async () => {
|
|
281
|
+
const harness = createHarness(row({ status: OutboxMessageStatus.blocked }));
|
|
282
|
+
|
|
283
|
+
await harness.service.updateDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID, {
|
|
284
|
+
subject: "Invoice, corrected",
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
assert.equal(applied(harness).at(0)?.status, OutboxMessageStatus.draft);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("writes no status on a row that is already a draft", async () => {
|
|
291
|
+
const harness = createHarness(row({}));
|
|
292
|
+
|
|
293
|
+
await harness.service.updateDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID, {
|
|
294
|
+
subject: "Invoice, corrected",
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
assert.deepEqual(applied(harness), [{ subject: "Invoice, corrected" }]);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("a message that moves while the request is deciding", () => {
|
|
302
|
+
it("refuses the edit rather than pulling a queued row back to `draft`", async () => {
|
|
303
|
+
// The send this loses to has an event on the wire, and `draft` is inside
|
|
304
|
+
// the fence that event has to pass.
|
|
305
|
+
const harness = createHarness(row({ status: OutboxMessageStatus.failed }), {
|
|
306
|
+
movesOnRead: OutboxMessageStatus.queued,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await assert.rejects(
|
|
310
|
+
() =>
|
|
311
|
+
harness.service.updateDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID, {
|
|
312
|
+
toAddresses: ["them@example.com"],
|
|
313
|
+
}),
|
|
314
|
+
(error: unknown) => {
|
|
315
|
+
assert.ok(error instanceof ConflictError);
|
|
316
|
+
assert.equal(error.statusCode, 409);
|
|
317
|
+
return true;
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
assert.equal(harness.status(), OutboxMessageStatus.queued);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("refuses the second of two sends racing the same row", async () => {
|
|
325
|
+
const harness = createHarness(row({}), {
|
|
326
|
+
movesOnRead: OutboxMessageStatus.sending,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
await assert.rejects(
|
|
330
|
+
() => harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
|
|
331
|
+
(error: unknown) => {
|
|
332
|
+
assert.ok(error instanceof ConflictError);
|
|
333
|
+
return true;
|
|
334
|
+
},
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe("a message that is not the user's to edit", () => {
|
|
342
|
+
for (const status of [
|
|
343
|
+
OutboxMessageStatus.queued,
|
|
344
|
+
OutboxMessageStatus.sending,
|
|
345
|
+
OutboxMessageStatus.sent,
|
|
346
|
+
OutboxMessageStatus.unfiled,
|
|
347
|
+
]) {
|
|
348
|
+
it(`refuses the edit of a \`${status}\` row`, async () => {
|
|
349
|
+
const harness = createHarness(row({ status }));
|
|
350
|
+
|
|
351
|
+
await assert.rejects(
|
|
352
|
+
() =>
|
|
353
|
+
harness.service.updateDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID, {
|
|
354
|
+
toAddresses: ["them@example.com"],
|
|
355
|
+
}),
|
|
356
|
+
(error: unknown) => {
|
|
357
|
+
assert.ok(error instanceof ConflictError);
|
|
358
|
+
assert.equal(error.statusCode, 409);
|
|
359
|
+
return true;
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
assert.deepEqual(harness.writes, []);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
});
|
|
@@ -67,6 +67,15 @@ const createHarness = (stored: OutboxMessageItem): Harness => {
|
|
|
67
67
|
harness.statusWrites.push(status);
|
|
68
68
|
return { ...stored, status };
|
|
69
69
|
},
|
|
70
|
+
updateIfStatus: async (
|
|
71
|
+
_configId: string,
|
|
72
|
+
_id: string,
|
|
73
|
+
_expected: OutboxMessageItem["status"],
|
|
74
|
+
input: { status?: OutboxMessageItem["status"] },
|
|
75
|
+
) => {
|
|
76
|
+
if (input.status) harness.statusWrites.push(input.status);
|
|
77
|
+
return { ...stored, ...input };
|
|
78
|
+
},
|
|
70
79
|
delete: async (_configId: string, id: string) => {
|
|
71
80
|
harness.deleted.push(id);
|
|
72
81
|
},
|
package/src/outbox-queue.test.ts
CHANGED
|
@@ -72,6 +72,15 @@ const createHarness = (stored: OutboxMessageItem): Harness => {
|
|
|
72
72
|
harness.statusWrites.push(status);
|
|
73
73
|
return draft({ status });
|
|
74
74
|
},
|
|
75
|
+
updateIfStatus: async (
|
|
76
|
+
_configId: string,
|
|
77
|
+
_id: string,
|
|
78
|
+
_expected: OutboxMessageItem["status"],
|
|
79
|
+
input: { status?: OutboxMessageItem["status"] },
|
|
80
|
+
) => {
|
|
81
|
+
if (input.status) harness.statusWrites.push(input.status);
|
|
82
|
+
return draft(input as Partial<OutboxMessageItem>);
|
|
83
|
+
},
|
|
75
84
|
} as unknown as IOutboxMessageRepository;
|
|
76
85
|
|
|
77
86
|
harness.service = new OutboxQueueService({
|
package/src/outbox-queue.ts
CHANGED
|
@@ -9,6 +9,7 @@ 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";
|
|
12
|
+
import { isOpenForWork } from "./outbox-status.js";
|
|
12
13
|
|
|
13
14
|
interface SendMessageEvent {
|
|
14
15
|
type: "SEND_MESSAGE";
|
|
@@ -87,6 +88,38 @@ const hasNowhereToGo = (message: {
|
|
|
87
88
|
const NO_RECIPIENT_MESSAGE =
|
|
88
89
|
"This message has nobody to send to. Add a recipient before sending it.";
|
|
89
90
|
|
|
91
|
+
const ENQUEUE_FAILED_MESSAGE =
|
|
92
|
+
"This message could not be handed to the outgoing queue, so it was not sent. Send it again.";
|
|
93
|
+
|
|
94
|
+
const MOVED_WHILE_EDITING_MESSAGE =
|
|
95
|
+
"This message started sending while it was being edited, so the change was not saved. Open the Outbox to see where it stands.";
|
|
96
|
+
|
|
97
|
+
const MOVED_WHILE_SENDING_MESSAGE =
|
|
98
|
+
"This message already left the queue and cannot be sent again. Open the Outbox to see where it stands.";
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Where a row goes when the enqueue reports a failure — never `draft`, and
|
|
102
|
+
* never back to `draft` where it came from.
|
|
103
|
+
*
|
|
104
|
+
* A throw from `SendMessage` does not prove the broker refused the event. This
|
|
105
|
+
* queue is not FIFO on the send path and carries no deduplication id, so a
|
|
106
|
+
* timeout or a lost response leaves an event that may still be delivered. And
|
|
107
|
+
* `draft` is inside the worker's send fence (`SENDABLE_STATUSES`, smtp-worker
|
|
108
|
+
* `send-message-core.ts`): a row put back there is sendable by the landed event
|
|
109
|
+
* and by the user pressing Send, which is two copies of one message. `failed`
|
|
110
|
+
* is outside that fence, so the landed event is dropped on arrival, and a
|
|
111
|
+
* `failed` row is editable and sendable by hand (#933) — the recovery survives.
|
|
112
|
+
*
|
|
113
|
+
* `blocked` is outside the fence too and names a cause the enqueue did not
|
|
114
|
+
* change, so a row that came from there goes back to it.
|
|
115
|
+
*/
|
|
116
|
+
const settledStatusFor = (
|
|
117
|
+
priorStatus: OutboxMessageItem["status"],
|
|
118
|
+
): OutboxMessageItem["status"] =>
|
|
119
|
+
priorStatus === OutboxMessageStatus.blocked
|
|
120
|
+
? OutboxMessageStatus.blocked
|
|
121
|
+
: OutboxMessageStatus.failed;
|
|
122
|
+
|
|
90
123
|
const generateMessageId = (domain: string): string => {
|
|
91
124
|
const timestamp = Date.now();
|
|
92
125
|
const random = randomUUID().replace(/-/g, "").slice(0, 16);
|
|
@@ -168,16 +201,30 @@ export class OutboxQueueService {
|
|
|
168
201
|
outboxMessageId,
|
|
169
202
|
"act",
|
|
170
203
|
);
|
|
171
|
-
if (existing.status
|
|
204
|
+
if (!isOpenForWork(existing.status)) {
|
|
172
205
|
throw new ConflictError(
|
|
173
206
|
`This message is already ${existing.status} and can no longer be edited as a draft. Start a new message to change it.`,
|
|
174
207
|
);
|
|
175
208
|
}
|
|
176
209
|
|
|
177
|
-
|
|
210
|
+
// Editing a settled failure returns the row to `draft`: it is no longer the
|
|
211
|
+
// message that failed, and the Outbox renders a `failed` row with its
|
|
212
|
+
// `lastError` — a failure reported against text that never went out. The
|
|
213
|
+
// same row moves to Drafts carrying its content, recipients and
|
|
214
|
+
// attachments, so there is no copy to reconcile.
|
|
215
|
+
//
|
|
216
|
+
// Conditional on the status this decision was read from. A concurrent send
|
|
217
|
+
// can move the row to `queued` between the two, and an unconditional write
|
|
218
|
+
// would pull it back to `draft` with its event already on the wire — the
|
|
219
|
+
// worker's fence takes `draft`, so that row goes out and stays sendable.
|
|
220
|
+
const updated = await this.outboxMessageService.updateIfStatus(
|
|
178
221
|
accountConfigId,
|
|
179
222
|
outboxMessageId,
|
|
223
|
+
existing.status,
|
|
180
224
|
{
|
|
225
|
+
...(existing.status !== OutboxMessageStatus.draft && {
|
|
226
|
+
status: OutboxMessageStatus.draft,
|
|
227
|
+
}),
|
|
181
228
|
...(input.toAddresses !== undefined && {
|
|
182
229
|
toAddresses: input.toAddresses,
|
|
183
230
|
}),
|
|
@@ -196,6 +243,7 @@ export class OutboxQueueService {
|
|
|
196
243
|
}),
|
|
197
244
|
},
|
|
198
245
|
);
|
|
246
|
+
if (!updated) throw new ConflictError(MOVED_WHILE_EDITING_MESSAGE);
|
|
199
247
|
|
|
200
248
|
this.log.info({ outboxMessageId }, "Updated draft outbox message");
|
|
201
249
|
|
|
@@ -211,11 +259,7 @@ export class OutboxQueueService {
|
|
|
211
259
|
outboxMessageId,
|
|
212
260
|
"act",
|
|
213
261
|
);
|
|
214
|
-
if (
|
|
215
|
-
existing.status !== OutboxMessageStatus.draft &&
|
|
216
|
-
existing.status !== OutboxMessageStatus.failed &&
|
|
217
|
-
existing.status !== OutboxMessageStatus.blocked
|
|
218
|
-
) {
|
|
262
|
+
if (!isOpenForWork(existing.status)) {
|
|
219
263
|
throw new ConflictError(
|
|
220
264
|
`This message is already ${existing.status} and cannot be sent again. Open the Outbox to see where it stands.`,
|
|
221
265
|
);
|
|
@@ -225,20 +269,25 @@ export class OutboxQueueService {
|
|
|
225
269
|
throw new BadRequestError(NO_RECIPIENT_MESSAGE);
|
|
226
270
|
}
|
|
227
271
|
|
|
228
|
-
|
|
272
|
+
// Conditional on the status this send was decided against, so two presses —
|
|
273
|
+
// or a press racing the worker — produce one queued row and one conflict
|
|
274
|
+
// rather than two events for the same message.
|
|
275
|
+
const updated = await this.outboxMessageService.updateIfStatus(
|
|
229
276
|
accountConfigId,
|
|
230
277
|
outboxMessageId,
|
|
231
|
-
|
|
278
|
+
existing.status,
|
|
279
|
+
{ status: OutboxMessageStatus.queued },
|
|
232
280
|
);
|
|
281
|
+
if (!updated) throw new ConflictError(MOVED_WHILE_SENDING_MESSAGE);
|
|
233
282
|
|
|
234
283
|
// `queued` is a dead end for a row the queue never accepted: `send` takes
|
|
235
284
|
// draft, failed and blocked, `deleteDraft` those three plus unfiled, so a
|
|
236
285
|
// row parked at `queued` by a failed enqueue is neither sendable nor
|
|
237
|
-
// discardable (#845.8).
|
|
238
|
-
//
|
|
286
|
+
// discardable (#845.8). Settle it and let the enqueue failure surface —
|
|
287
|
+
// the row stays reachable, the caller still hears no.
|
|
239
288
|
await this.enqueueSend(existing.accountId, outboxMessageId).catch(
|
|
240
289
|
async (error: unknown) => {
|
|
241
|
-
await this.
|
|
290
|
+
await this.settleUnqueued(
|
|
242
291
|
accountConfigId,
|
|
243
292
|
outboxMessageId,
|
|
244
293
|
existing.status,
|
|
@@ -282,7 +331,21 @@ export class OutboxQueueService {
|
|
|
282
331
|
status: OutboxMessageStatus.queued,
|
|
283
332
|
});
|
|
284
333
|
|
|
285
|
-
|
|
334
|
+
// Same dead end as in `send`: a row parked at `queued` by an enqueue that
|
|
335
|
+
// threw is neither sendable nor discardable (#845.8, #931, #936). This row
|
|
336
|
+
// is new, so there is no prior status — it settles at `failed`, carrying
|
|
337
|
+
// the reason, which keeps the composed text editable and sendable while
|
|
338
|
+
// staying outside the fence a landed event has to pass.
|
|
339
|
+
await this.enqueueSend(input.accountId, outbox.outboxMessageId).catch(
|
|
340
|
+
async (error: unknown) => {
|
|
341
|
+
await this.settleUnqueued(
|
|
342
|
+
input.accountConfigId,
|
|
343
|
+
outbox.outboxMessageId,
|
|
344
|
+
OutboxMessageStatus.failed,
|
|
345
|
+
);
|
|
346
|
+
throw error;
|
|
347
|
+
},
|
|
348
|
+
);
|
|
286
349
|
|
|
287
350
|
this.log.info(
|
|
288
351
|
{ outboxMessageId: outbox.outboxMessageId, accountId: input.accountId },
|
|
@@ -330,6 +393,45 @@ export class OutboxQueueService {
|
|
|
330
393
|
this.log.info({ outboxMessageId }, "Deleted outbox message");
|
|
331
394
|
};
|
|
332
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Take a row back out of `queued` after the enqueue reported a failure.
|
|
398
|
+
*
|
|
399
|
+
* Conditional on `queued`, because the event may have landed regardless — an
|
|
400
|
+
* error from `SendMessage` says the response was lost, not that the broker
|
|
401
|
+
* refused it. If the worker already has the row, it holds the newer truth and
|
|
402
|
+
* this write does nothing.
|
|
403
|
+
*
|
|
404
|
+
* Never rejects. The enqueue failure is what went wrong and what the caller
|
|
405
|
+
* has to hear; a settle that also fails would replace it with the wrong
|
|
406
|
+
* cause, so it is logged and the row is left where #936 found it — which the
|
|
407
|
+
* caller's error at least names.
|
|
408
|
+
*/
|
|
409
|
+
private settleUnqueued = async (
|
|
410
|
+
accountConfigId: string,
|
|
411
|
+
outboxMessageId: string,
|
|
412
|
+
priorStatus: OutboxMessageItem["status"],
|
|
413
|
+
): Promise<void> => {
|
|
414
|
+
const status = settledStatusFor(priorStatus);
|
|
415
|
+
await this.outboxMessageService
|
|
416
|
+
.updateIfStatus(
|
|
417
|
+
accountConfigId,
|
|
418
|
+
outboxMessageId,
|
|
419
|
+
OutboxMessageStatus.queued,
|
|
420
|
+
{
|
|
421
|
+
status,
|
|
422
|
+
...(status === OutboxMessageStatus.failed && {
|
|
423
|
+
lastError: ENQUEUE_FAILED_MESSAGE,
|
|
424
|
+
}),
|
|
425
|
+
},
|
|
426
|
+
)
|
|
427
|
+
.catch((settleError: unknown) => {
|
|
428
|
+
this.log.error(
|
|
429
|
+
{ outboxMessageId, settleError: String(settleError) },
|
|
430
|
+
"Could not settle an outbox message the queue refused",
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
};
|
|
434
|
+
|
|
333
435
|
private enqueueSend = async (
|
|
334
436
|
accountId: string,
|
|
335
437
|
outboxMessageId: string,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { OutboxMessageItem } from "@remit/data-ports";
|
|
2
|
+
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether the message is still the user's to work on: nothing of it is on the
|
|
6
|
+
* wire, so Send, Edit and its attachments all apply.
|
|
7
|
+
*
|
|
8
|
+
* One predicate for all three because they answer the same question, and
|
|
9
|
+
* splitting them is what dead-ended a refused message. Sending took `failed`
|
|
10
|
+
* while editing took only `draft`, so a message the server turned away for a
|
|
11
|
+
* bad address could be re-sent unchanged or deleted, and nothing else — Retry
|
|
12
|
+
* queued the same envelope, and Edit took a 409 on the flush that precedes the
|
|
13
|
+
* send (#933).
|
|
14
|
+
*
|
|
15
|
+
* Everything absent is in the worker's hands or settled: `queued` and `sending`
|
|
16
|
+
* are mid-flight, `sent` and `unfiled` were delivered.
|
|
17
|
+
*/
|
|
18
|
+
export const isOpenForWork = (status: OutboxMessageItem["status"]): boolean =>
|
|
19
|
+
status === OutboxMessageStatus.draft ||
|
|
20
|
+
status === OutboxMessageStatus.failed ||
|
|
21
|
+
status === OutboxMessageStatus.blocked;
|