@remit/mailbox-service 0.0.73 → 0.0.75
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 +1 -1
- package/src/message-delete-junk-reconcile.test.ts +2 -1
- package/src/message-delete-no-trash.test.ts +2 -0
- package/src/message-move-copy.test.ts +6 -1
- package/src/message-move-empty-trash.test.ts +2 -0
- package/src/message-move-same-mailbox.test.ts +2 -1
- package/src/message-move.ts +16 -2
- package/src/message-sync-junk-harvest.test.ts +3 -1
- package/src/message-sync.ts +23 -6
- package/src/outbox-attachment.ts +11 -8
- package/src/outbox-failed-recovery.test.ts +366 -0
- package/src/outbox-queue-settled-row.test.ts +9 -0
- package/src/outbox-queue.test.ts +9 -0
- package/src/outbox-queue.ts +115 -13
- package/src/outbox-status.ts +21 -0
- package/src/placement-move-unsettled.test.ts +2 -0
- package/src/placement-move.ts +15 -1
- package/src/spam-report.test.ts +2 -1
- package/src/test-helpers/folder-roles.ts +16 -1
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
IThreadMessageRepository,
|
|
9
9
|
} from "@remit/data-ports";
|
|
10
10
|
import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
|
|
11
|
-
import { trashRole } from "./test-helpers/folder-roles.js";
|
|
11
|
+
import { NO_JUNK_ROLES, trashRole } from "./test-helpers/folder-roles.js";
|
|
12
12
|
|
|
13
13
|
const ACCOUNT = "acc-1";
|
|
14
14
|
const ACCOUNT_CONFIG = "cfg-1";
|
|
@@ -57,6 +57,7 @@ const buildWorld = (trashExists: boolean) => {
|
|
|
57
57
|
const mailboxSpecialUseService = {
|
|
58
58
|
findTrashMailbox: async () => trash,
|
|
59
59
|
resolveTrashRole: async () => trashRole(trash),
|
|
60
|
+
resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
|
|
60
61
|
} as unknown as IMailboxSpecialUseRepository;
|
|
61
62
|
|
|
62
63
|
const addressService = {
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
StaleTrashAppointmentError,
|
|
16
16
|
UnconfirmedTrashMailboxError,
|
|
17
17
|
} from "./message-move.js";
|
|
18
|
+
import { NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
|
|
18
19
|
|
|
19
20
|
const ACCOUNT = "acc-1";
|
|
20
21
|
const ACCOUNT_CONFIG = "cfg-1";
|
|
@@ -80,6 +81,7 @@ const buildWorld = (
|
|
|
80
81
|
|
|
81
82
|
const mailboxSpecialUseService = {
|
|
82
83
|
resolveTrashRole: async () => trashResolution,
|
|
84
|
+
resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
|
|
83
85
|
} as unknown as IMailboxSpecialUseRepository;
|
|
84
86
|
|
|
85
87
|
const addressService = {
|
|
@@ -15,7 +15,11 @@ import { MailboxCursorState } from "@remit/domain-enums";
|
|
|
15
15
|
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
16
16
|
import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
|
|
17
17
|
import { MessageSyncService } from "./message-sync.js";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
NO_JUNK_ROLES,
|
|
20
|
+
noFolderRoles,
|
|
21
|
+
trashRole,
|
|
22
|
+
} from "./test-helpers/folder-roles.js";
|
|
19
23
|
import type { IImapConnection, ImapMessage } from "./types.js";
|
|
20
24
|
|
|
21
25
|
const stubAddressService = (): IAddressRepository =>
|
|
@@ -176,6 +180,7 @@ const buildWorld = () => {
|
|
|
176
180
|
const mailboxSpecialUseService = {
|
|
177
181
|
findTrashMailbox: async () => null,
|
|
178
182
|
resolveTrashRole: async () => trashRole(null),
|
|
183
|
+
resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
|
|
179
184
|
} as unknown as IMailboxSpecialUseRepository;
|
|
180
185
|
|
|
181
186
|
const config: MessageMoveConfig = {
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
StaleTrashAppointmentError,
|
|
23
23
|
UnconfirmedTrashMailboxError,
|
|
24
24
|
} from "./message-move.js";
|
|
25
|
+
import { NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
|
|
25
26
|
|
|
26
27
|
const ACCOUNT = "acc-1";
|
|
27
28
|
const ACCOUNT_CONFIG = "cfg-1";
|
|
@@ -100,6 +101,7 @@ const buildWorld = (
|
|
|
100
101
|
} as unknown as IAddressRepository,
|
|
101
102
|
mailboxSpecialUseService: {
|
|
102
103
|
resolveTrashRole: async () => trashResolution,
|
|
104
|
+
resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
|
|
103
105
|
} as unknown as IMailboxSpecialUseRepository,
|
|
104
106
|
threadMessageService,
|
|
105
107
|
sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
|
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
IThreadMessageRepository,
|
|
9
9
|
} from "@remit/data-ports";
|
|
10
10
|
import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
|
|
11
|
-
import { trashRole } from "./test-helpers/folder-roles.js";
|
|
11
|
+
import { NO_JUNK_ROLES, trashRole } from "./test-helpers/folder-roles.js";
|
|
12
12
|
|
|
13
13
|
const stubAddressService = (): IAddressRepository =>
|
|
14
14
|
({
|
|
@@ -68,6 +68,7 @@ const buildWorld = () => {
|
|
|
68
68
|
const mailboxSpecialUseService = {
|
|
69
69
|
findTrashMailbox: async () => null,
|
|
70
70
|
resolveTrashRole: async () => trashRole(null),
|
|
71
|
+
resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
|
|
71
72
|
} as unknown as IMailboxSpecialUseRepository;
|
|
72
73
|
|
|
73
74
|
const config: MessageMoveConfig = {
|
package/src/message-move.ts
CHANGED
|
@@ -395,6 +395,12 @@ export class MessageMoveService {
|
|
|
395
395
|
|
|
396
396
|
// Process move to trash
|
|
397
397
|
if (trashMailbox && moveToTrashMessages.length > 0) {
|
|
398
|
+
// Every message in the batch shares the config, so its Junk and Trash
|
|
399
|
+
// folders resolve once rather than once per message.
|
|
400
|
+
const configJunkRoles =
|
|
401
|
+
await this.mailboxSpecialUseService.resolveJunkRolesForConfig(
|
|
402
|
+
accountConfigId,
|
|
403
|
+
);
|
|
398
404
|
for (const { messageId, message, sourceMailbox } of moveToTrashMessages) {
|
|
399
405
|
// Update local state optimistically
|
|
400
406
|
await this.messageService.updateForMove(messageId, {
|
|
@@ -413,7 +419,10 @@ export class MessageMoveService {
|
|
|
413
419
|
true,
|
|
414
420
|
);
|
|
415
421
|
|
|
416
|
-
await this.addressService.reconcileJunkOnlyForMessage(
|
|
422
|
+
await this.addressService.reconcileJunkOnlyForMessage(
|
|
423
|
+
messageId,
|
|
424
|
+
configJunkRoles,
|
|
425
|
+
);
|
|
417
426
|
|
|
418
427
|
events.push({
|
|
419
428
|
type: "MESSAGE_DELETE",
|
|
@@ -548,7 +557,12 @@ export class MessageMoveService {
|
|
|
548
557
|
isMovingToTrash,
|
|
549
558
|
);
|
|
550
559
|
|
|
551
|
-
await this.addressService.reconcileJunkOnlyForMessage(
|
|
560
|
+
await this.addressService.reconcileJunkOnlyForMessage(
|
|
561
|
+
messageId,
|
|
562
|
+
await this.mailboxSpecialUseService.resolveJunkRolesForConfig(
|
|
563
|
+
accountConfigId,
|
|
564
|
+
),
|
|
565
|
+
);
|
|
552
566
|
|
|
553
567
|
// If moving FROM Trash, clear isDeleted
|
|
554
568
|
if (isMovingFromTrash) {
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
addressSightingIn,
|
|
20
20
|
MessageSyncService,
|
|
21
21
|
} from "./message-sync.js";
|
|
22
|
-
import { folderRoles } from "./test-helpers/folder-roles.js";
|
|
22
|
+
import { folderRoles, NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
|
|
23
23
|
import type { ImapEnvelope, ImapMessage } from "./types.js";
|
|
24
24
|
|
|
25
25
|
const stub = <T>(): T => ({}) as T;
|
|
@@ -141,6 +141,7 @@ const save = async (
|
|
|
141
141
|
).saveMessage(mailbox, "acct-1", "cfg-1", msg, {
|
|
142
142
|
junkMailboxId: role === "Junk" ? mailbox.mailboxId : null,
|
|
143
143
|
trashMailboxId: role === "Trash" ? mailbox.mailboxId : null,
|
|
144
|
+
configJunkRoles: NO_JUNK_ROLES,
|
|
144
145
|
});
|
|
145
146
|
|
|
146
147
|
return saved;
|
|
@@ -155,6 +156,7 @@ describe("what a mailbox says about the addresses on its messages", () => {
|
|
|
155
156
|
const ROLES = (junk: string | null, trash: string | null) => ({
|
|
156
157
|
junkMailboxId: junk,
|
|
157
158
|
trashMailboxId: trash,
|
|
159
|
+
configJunkRoles: NO_JUNK_ROLES,
|
|
158
160
|
});
|
|
159
161
|
|
|
160
162
|
it("reads the folder the account appointed, not the folder’s name", () => {
|
package/src/message-sync.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
ThreadMessageItem,
|
|
14
14
|
} from "@remit/data-ports";
|
|
15
15
|
import { storedDisplayName } from "@remit/data-ports/display-name";
|
|
16
|
+
import type { JunkRoleMailboxes } from "@remit/data-ports/folder-role";
|
|
16
17
|
import {
|
|
17
18
|
deriveAddressId,
|
|
18
19
|
deriveBodyPartId,
|
|
@@ -92,6 +93,14 @@ export type AddressSighting = "junk" | "discarded" | "correspondent";
|
|
|
92
93
|
export interface AccountFolderRoles {
|
|
93
94
|
readonly junkMailboxId: string | null;
|
|
94
95
|
readonly trashMailboxId: string | null;
|
|
96
|
+
/**
|
|
97
|
+
* The same two roles across every account under the config, for the
|
|
98
|
+
* reconcile that follows a save into Junk. The address book is keyed by
|
|
99
|
+
* config, so a predicate over an address weighs sightings from all of them —
|
|
100
|
+
* resolving only this account withholds a sender on one and restores it on
|
|
101
|
+
* the next.
|
|
102
|
+
*/
|
|
103
|
+
readonly configJunkRoles: JunkRoleMailboxes;
|
|
95
104
|
}
|
|
96
105
|
|
|
97
106
|
/**
|
|
@@ -462,7 +471,7 @@ export class MessageSyncService {
|
|
|
462
471
|
// which catches its own error and reports a `failed` outcome instead of
|
|
463
472
|
// rejecting. So one bad message can no longer abort the whole batch (the
|
|
464
473
|
// poison pill that previously froze the mailbox, #817).
|
|
465
|
-
const roles = await this.folderRolesFor(accountId);
|
|
474
|
+
const roles = await this.folderRolesFor(accountId, accountConfigId);
|
|
466
475
|
const outcomes = await pMap(
|
|
467
476
|
applicable,
|
|
468
477
|
(msg) =>
|
|
@@ -697,7 +706,7 @@ export class MessageSyncService {
|
|
|
697
706
|
const newMessages =
|
|
698
707
|
newUids.length > 0 ? await this.fetchMessageBatch(newUids) : [];
|
|
699
708
|
const applicable = newMessages.filter((msg) => msg.envelope !== undefined);
|
|
700
|
-
const roles = await this.folderRolesFor(accountId);
|
|
709
|
+
const roles = await this.folderRolesFor(accountId, accountConfigId);
|
|
701
710
|
const outcomes = await pMap(
|
|
702
711
|
applicable,
|
|
703
712
|
(msg) =>
|
|
@@ -925,7 +934,7 @@ export class MessageSyncService {
|
|
|
925
934
|
);
|
|
926
935
|
}
|
|
927
936
|
|
|
928
|
-
const roles = await this.folderRolesFor(accountId);
|
|
937
|
+
const roles = await this.folderRolesFor(accountId, accountConfigId);
|
|
929
938
|
const outcomes = await pMap(
|
|
930
939
|
applicable,
|
|
931
940
|
(msg) =>
|
|
@@ -1280,14 +1289,19 @@ export class MessageSyncService {
|
|
|
1280
1289
|
* batch rather than per message: the lookup reads an appointment row and
|
|
1281
1290
|
* the account’s mailbox list, and every message in a batch shares both.
|
|
1282
1291
|
*/
|
|
1283
|
-
private async folderRolesFor(
|
|
1284
|
-
|
|
1292
|
+
private async folderRolesFor(
|
|
1293
|
+
accountId: string,
|
|
1294
|
+
accountConfigId: string,
|
|
1295
|
+
): Promise<AccountFolderRoles> {
|
|
1296
|
+
const [junk, trash, configJunkRoles] = await Promise.all([
|
|
1285
1297
|
this.mailboxSpecialUseService.findJunkMailbox(accountId),
|
|
1286
1298
|
this.mailboxSpecialUseService.findTrashMailbox(accountId),
|
|
1299
|
+
this.mailboxSpecialUseService.resolveJunkRolesForConfig(accountConfigId),
|
|
1287
1300
|
]);
|
|
1288
1301
|
return {
|
|
1289
1302
|
junkMailboxId: junk?.mailboxId ?? null,
|
|
1290
1303
|
trashMailboxId: trash?.mailboxId ?? null,
|
|
1304
|
+
configJunkRoles,
|
|
1291
1305
|
};
|
|
1292
1306
|
}
|
|
1293
1307
|
|
|
@@ -1432,7 +1446,10 @@ export class MessageSyncService {
|
|
|
1432
1446
|
owned = created || item.mailboxId === mailboxId;
|
|
1433
1447
|
|
|
1434
1448
|
if (sighting === "junk") {
|
|
1435
|
-
await repos.address.reconcileJunkOnlyForMessage(
|
|
1449
|
+
await repos.address.reconcileJunkOnlyForMessage(
|
|
1450
|
+
messageId,
|
|
1451
|
+
roles.configJunkRoles,
|
|
1452
|
+
);
|
|
1436
1453
|
}
|
|
1437
1454
|
|
|
1438
1455
|
await this.createThreadForMessage(
|
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;
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
UpdateMessageMoveInput,
|
|
21
21
|
} from "@remit/data-ports";
|
|
22
22
|
import { PlacementMoveService } from "./placement-move.js";
|
|
23
|
+
import { noFolderRoles } from "./test-helpers/folder-roles.js";
|
|
23
24
|
|
|
24
25
|
const ACCOUNT_ID = "acc-pm";
|
|
25
26
|
const ACCOUNT_CONFIG_ID = "cfg-pm";
|
|
@@ -111,6 +112,7 @@ const buildHarness = (): Harness => {
|
|
|
111
112
|
threadMessageService,
|
|
112
113
|
markerService,
|
|
113
114
|
addressService,
|
|
115
|
+
mailboxSpecialUseService: noFolderRoles,
|
|
114
116
|
sqsQueueUrl: "https://sqs.eu-west-1.amazonaws.com/000/message-mgmt",
|
|
115
117
|
moveSettleTimeoutMs: 200,
|
|
116
118
|
moveSettlePollMs: 10,
|
package/src/placement-move.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
|
|
3
3
|
import type {
|
|
4
4
|
IAddressRepository,
|
|
5
|
+
IMailboxSpecialUseRepository,
|
|
5
6
|
IMessagePlacementMoveRepository,
|
|
6
7
|
IMessageRepository,
|
|
7
8
|
IThreadMessageRepository,
|
|
@@ -44,6 +45,12 @@ export interface PlacementMoveConfig {
|
|
|
44
45
|
threadMessageService: IThreadMessageRepository;
|
|
45
46
|
markerService: IMessagePlacementMoveRepository;
|
|
46
47
|
addressService: IAddressRepository;
|
|
48
|
+
/**
|
|
49
|
+
* Required, not optional: this mover carries automatic spam placement, and
|
|
50
|
+
* one built without the folder-role seam would silently leave the sender of
|
|
51
|
+
* every auto-filed spam suggestible in the compose picker.
|
|
52
|
+
*/
|
|
53
|
+
mailboxSpecialUseService: IMailboxSpecialUseRepository;
|
|
47
54
|
sqsQueueUrl: string;
|
|
48
55
|
sqsEndpoint?: string;
|
|
49
56
|
logger?: PlacementMoveLogger;
|
|
@@ -87,6 +94,7 @@ export class PlacementMoveService {
|
|
|
87
94
|
private threadMessageService: IThreadMessageRepository;
|
|
88
95
|
private markerService: IMessagePlacementMoveRepository;
|
|
89
96
|
private addressService: IAddressRepository;
|
|
97
|
+
private mailboxSpecialUseService: IMailboxSpecialUseRepository;
|
|
90
98
|
private sqs: SQSClient;
|
|
91
99
|
private queueUrl: string;
|
|
92
100
|
private log: PlacementMoveLogger;
|
|
@@ -98,6 +106,7 @@ export class PlacementMoveService {
|
|
|
98
106
|
this.threadMessageService = config.threadMessageService;
|
|
99
107
|
this.markerService = config.markerService;
|
|
100
108
|
this.addressService = config.addressService;
|
|
109
|
+
this.mailboxSpecialUseService = config.mailboxSpecialUseService;
|
|
101
110
|
this.queueUrl = config.sqsQueueUrl;
|
|
102
111
|
this.log = config.logger ?? noopLogger;
|
|
103
112
|
this.moveSettleTimeoutMs =
|
|
@@ -200,7 +209,12 @@ export class PlacementMoveService {
|
|
|
200
209
|
originalUid: message.uid,
|
|
201
210
|
});
|
|
202
211
|
|
|
203
|
-
await this.addressService.reconcileJunkOnlyForMessage(
|
|
212
|
+
await this.addressService.reconcileJunkOnlyForMessage(
|
|
213
|
+
messageId,
|
|
214
|
+
await this.mailboxSpecialUseService.resolveJunkRolesForConfig(
|
|
215
|
+
accountConfigId,
|
|
216
|
+
),
|
|
217
|
+
);
|
|
204
218
|
|
|
205
219
|
// The queue kick is a serious operational step, not a routine one — a
|
|
206
220
|
// failure here MUST propagate (never swallowed): the marker stays
|
package/src/spam-report.test.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
NoJunkMailboxError,
|
|
20
20
|
SpamReportService,
|
|
21
21
|
} from "./spam-report.js";
|
|
22
|
-
import { trashRole } from "./test-helpers/folder-roles.js";
|
|
22
|
+
import { NO_JUNK_ROLES, trashRole } from "./test-helpers/folder-roles.js";
|
|
23
23
|
|
|
24
24
|
const ACCOUNT = "acc-1";
|
|
25
25
|
const ACCOUNT_CONFIG = "cfg-1";
|
|
@@ -235,6 +235,7 @@ const buildWorld = (
|
|
|
235
235
|
findJunkMailbox: async () => junkMailbox,
|
|
236
236
|
findTrashMailbox: async () => null,
|
|
237
237
|
resolveTrashRole: async () => trashRole(null),
|
|
238
|
+
resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
|
|
238
239
|
} as unknown as IMailboxSpecialUseRepository;
|
|
239
240
|
|
|
240
241
|
const mailboxService = {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { IMailboxSpecialUseRepository } from "@remit/data-ports";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
JunkRoleMailboxes,
|
|
4
|
+
RoleResolution,
|
|
5
|
+
} from "@remit/data-ports/folder-role";
|
|
3
6
|
|
|
4
7
|
type RoleMailbox = { mailboxId: string; fullPath: string };
|
|
5
8
|
|
|
@@ -24,6 +27,7 @@ export const noFolderRoles = {
|
|
|
24
27
|
findJunkMailbox: async () => null,
|
|
25
28
|
findTrashMailbox: async () => null,
|
|
26
29
|
resolveTrashRole: async () => trashRole(null),
|
|
30
|
+
resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
|
|
27
31
|
} as unknown as IMailboxSpecialUseRepository;
|
|
28
32
|
|
|
29
33
|
/** A folder-role map naming the mailboxes that hold Junk and Trash. */
|
|
@@ -41,11 +45,22 @@ export const folderRoles = (roles: {
|
|
|
41
45
|
: null,
|
|
42
46
|
findTrashMailbox: async () => trash,
|
|
43
47
|
resolveTrashRole: async () => trashRole(trash),
|
|
48
|
+
resolveJunkRolesForConfig: async () => ({
|
|
49
|
+
junkMailboxIds: roles.junkMailboxId ? [roles.junkMailboxId] : [],
|
|
50
|
+
trashMailboxIds: roles.trashMailboxId ? [roles.trashMailboxId] : [],
|
|
51
|
+
}),
|
|
44
52
|
} as unknown as IMailboxSpecialUseRepository;
|
|
45
53
|
};
|
|
46
54
|
|
|
55
|
+
/** An account whose config resolves no Junk and no Trash anywhere. */
|
|
56
|
+
export const NO_JUNK_ROLES: JunkRoleMailboxes = {
|
|
57
|
+
junkMailboxIds: [],
|
|
58
|
+
trashMailboxIds: [],
|
|
59
|
+
};
|
|
60
|
+
|
|
47
61
|
/** The same account, as the resolved map `saveMessage` is handed. */
|
|
48
62
|
export const NO_FOLDER_ROLES = {
|
|
49
63
|
junkMailboxId: null,
|
|
50
64
|
trashMailboxId: null,
|
|
65
|
+
configJunkRoles: NO_JUNK_ROLES,
|
|
51
66
|
};
|