@remit/mailbox-service 0.0.103 → 0.0.105
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 +5 -1
- package/src/index.ts +2 -0
- package/src/mailbox-delete-intent.test.ts +2 -2
- package/src/mailbox-management.ts +8 -1
- package/src/mailbox-rename-subtree.test.ts +35 -5
- package/src/mailbox-upstream.ts +12 -0
- package/src/outbox-attachment-content.test.ts +145 -0
- package/src/outbox-attachment-content.ts +99 -0
- package/src/outbox-attachment.ts +32 -0
- package/src/outbox-failed-recovery.test.ts +3 -1
- package/src/outbox-queue-settled-row.test.ts +1 -0
- package/src/outbox-queue.test.ts +79 -2
- package/src/outbox-queue.ts +29 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/mailbox-service",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.105",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
"types": "./src/account-credentials.ts",
|
|
26
26
|
"default": "./src/account-credentials.ts"
|
|
27
27
|
},
|
|
28
|
+
"./outbox-attachment-content": {
|
|
29
|
+
"types": "./src/outbox-attachment-content.ts",
|
|
30
|
+
"default": "./src/outbox-attachment-content.ts"
|
|
31
|
+
},
|
|
28
32
|
"./types": {
|
|
29
33
|
"types": "./src/types.ts",
|
|
30
34
|
"default": "./src/types.ts"
|
package/src/index.ts
CHANGED
|
@@ -227,7 +227,7 @@ describe("MailboxManagementService.failDelete", () => {
|
|
|
227
227
|
]);
|
|
228
228
|
const service = new MailboxManagementService(repo);
|
|
229
229
|
|
|
230
|
-
await service.failDelete("acc-1", "mbx-1");
|
|
230
|
+
await service.failDelete("acc-1", "mbx-1", "Permission denied");
|
|
231
231
|
|
|
232
232
|
assert.equal(byId.get("mbx-1")?.syncStatus, MailboxSyncStatus.failed);
|
|
233
233
|
assert.equal(byId.get("mbx-1")?.fullPath, "Receipts");
|
|
@@ -239,7 +239,7 @@ describe("MailboxManagementService.failDelete", () => {
|
|
|
239
239
|
const service = new MailboxManagementService(repo);
|
|
240
240
|
|
|
241
241
|
await assert.rejects(
|
|
242
|
-
service.failDelete("acc-1", "mbx-1"),
|
|
242
|
+
service.failDelete("acc-1", "mbx-1", "Permission denied"),
|
|
243
243
|
(error: unknown) => (error as Error).name === "NotFoundError",
|
|
244
244
|
);
|
|
245
245
|
});
|
|
@@ -598,6 +598,7 @@ export class MailboxManagementService {
|
|
|
598
598
|
mailboxId: string,
|
|
599
599
|
oldPath: string,
|
|
600
600
|
newPath: string,
|
|
601
|
+
reason: string,
|
|
601
602
|
): Promise<void> => {
|
|
602
603
|
const root = await this.mailboxService
|
|
603
604
|
.get(accountId, mailboxId)
|
|
@@ -643,6 +644,7 @@ export class MailboxManagementService {
|
|
|
643
644
|
from: [MailboxSyncStatus.pending],
|
|
644
645
|
wherePendingPath: row.pendingPath,
|
|
645
646
|
to: MailboxSyncStatus.failed,
|
|
647
|
+
set: { syncFailureReason: reason },
|
|
646
648
|
},
|
|
647
649
|
);
|
|
648
650
|
this.log.info(
|
|
@@ -811,10 +813,15 @@ export class MailboxManagementService {
|
|
|
811
813
|
* target is written, which is how the client tells a failed delete from a
|
|
812
814
|
* failed rename.
|
|
813
815
|
*/
|
|
814
|
-
failDelete = async (
|
|
816
|
+
failDelete = async (
|
|
817
|
+
accountId: string,
|
|
818
|
+
mailboxId: string,
|
|
819
|
+
reason: string,
|
|
820
|
+
): Promise<void> => {
|
|
815
821
|
const failed = await this.mailboxService.transition(accountId, mailboxId, {
|
|
816
822
|
from: [MailboxSyncStatus.deleting],
|
|
817
823
|
to: MailboxSyncStatus.failed,
|
|
824
|
+
set: { syncFailureReason: reason },
|
|
818
825
|
});
|
|
819
826
|
if (!failed) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
820
827
|
this.log.info(
|
|
@@ -361,7 +361,13 @@ describe("MailboxManagementService.syncRename — rows it must not claim", () =>
|
|
|
361
361
|
]);
|
|
362
362
|
const service = new MailboxManagementService(repo);
|
|
363
363
|
|
|
364
|
-
await service.failRename(
|
|
364
|
+
await service.failRename(
|
|
365
|
+
"acc-1",
|
|
366
|
+
"mbx-parent",
|
|
367
|
+
"Work",
|
|
368
|
+
"Projects",
|
|
369
|
+
"Permission denied",
|
|
370
|
+
);
|
|
365
371
|
|
|
366
372
|
assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.failed);
|
|
367
373
|
assert.equal(rowOf("mbx-other")?.syncStatus, MailboxSyncStatus.pending);
|
|
@@ -572,7 +578,13 @@ describe("MailboxManagementService.failRename", () => {
|
|
|
572
578
|
]);
|
|
573
579
|
const service = new MailboxManagementService(repo);
|
|
574
580
|
|
|
575
|
-
await service.failRename(
|
|
581
|
+
await service.failRename(
|
|
582
|
+
"acc-1",
|
|
583
|
+
"mbx-parent",
|
|
584
|
+
"Work",
|
|
585
|
+
"Projects",
|
|
586
|
+
"Permission denied",
|
|
587
|
+
);
|
|
576
588
|
|
|
577
589
|
for (const [id, path, target] of [
|
|
578
590
|
["mbx-parent", "Work", "Projects"],
|
|
@@ -593,7 +605,13 @@ describe("MailboxManagementService.failRename", () => {
|
|
|
593
605
|
const service = new MailboxManagementService(repo);
|
|
594
606
|
|
|
595
607
|
await assert.rejects(
|
|
596
|
-
service.failRename(
|
|
608
|
+
service.failRename(
|
|
609
|
+
"acc-1",
|
|
610
|
+
"mbx-parent",
|
|
611
|
+
"Work",
|
|
612
|
+
"Projects",
|
|
613
|
+
"Permission denied",
|
|
614
|
+
),
|
|
597
615
|
(error: unknown) => (error as Error).name === "NotFoundError",
|
|
598
616
|
);
|
|
599
617
|
});
|
|
@@ -606,7 +624,13 @@ describe("MailboxManagementService.failRename", () => {
|
|
|
606
624
|
const service = new MailboxManagementService(repo);
|
|
607
625
|
|
|
608
626
|
await assert.doesNotReject(
|
|
609
|
-
service.failRename(
|
|
627
|
+
service.failRename(
|
|
628
|
+
"acc-1",
|
|
629
|
+
"mbx-parent",
|
|
630
|
+
"Work",
|
|
631
|
+
"Projects",
|
|
632
|
+
"Permission denied",
|
|
633
|
+
),
|
|
610
634
|
);
|
|
611
635
|
});
|
|
612
636
|
|
|
@@ -627,7 +651,13 @@ describe("MailboxManagementService.failRename", () => {
|
|
|
627
651
|
/not ready/,
|
|
628
652
|
);
|
|
629
653
|
|
|
630
|
-
await service.failRename(
|
|
654
|
+
await service.failRename(
|
|
655
|
+
"acc-1",
|
|
656
|
+
"mbx-parent",
|
|
657
|
+
"Work",
|
|
658
|
+
"Projects",
|
|
659
|
+
"Permission denied",
|
|
660
|
+
);
|
|
631
661
|
assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.failed);
|
|
632
662
|
assert.equal(rowOf("mbx-parent")?.fullPath, "Work");
|
|
633
663
|
});
|
package/src/mailbox-upstream.ts
CHANGED
|
@@ -36,6 +36,18 @@ export const isMailboxPresentUpstream = (error: unknown): boolean => {
|
|
|
36
36
|
return /already exists/i.test(saidByServer(error));
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
const PERMANENT_REFUSALS = new Set(["NOPERM", "CANNOT"]);
|
|
40
|
+
|
|
41
|
+
export const isMailboxRefusedUpstream = (error: unknown): boolean =>
|
|
42
|
+
error instanceof Error &&
|
|
43
|
+
PERMANENT_REFUSALS.has(stringField(error, "serverResponseCode"));
|
|
44
|
+
|
|
45
|
+
export const upstreamFailureReason = (error: unknown): string => {
|
|
46
|
+
const serverText = stringField(error, "responseText").trim();
|
|
47
|
+
if (serverText.length > 0) return serverText;
|
|
48
|
+
return error instanceof Error ? error.message : String(error);
|
|
49
|
+
};
|
|
50
|
+
|
|
39
51
|
/**
|
|
40
52
|
* Something after the server RENAME failed — the local settle, not the
|
|
41
53
|
* mutation. The rename landed, so this is never a refused rename: marking the
|
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
loadOutboxAttachmentContents,
|
|
7
|
+
OutboxAttachmentUnavailableError,
|
|
8
|
+
} from "./outbox-attachment-content.js";
|
|
9
|
+
|
|
10
|
+
const OWNER = {
|
|
11
|
+
accountConfigId: "cfg-1",
|
|
12
|
+
accountId: "acc-1",
|
|
13
|
+
outboxMessageId: "ob-1",
|
|
14
|
+
};
|
|
15
|
+
const NOW = 1_000;
|
|
16
|
+
|
|
17
|
+
const row = (
|
|
18
|
+
outboxAttachmentId: string,
|
|
19
|
+
overrides: Partial<OutboxAttachmentItem> = {},
|
|
20
|
+
): OutboxAttachmentItem => ({
|
|
21
|
+
outboxAttachmentId,
|
|
22
|
+
outboxMessageId: OWNER.outboxMessageId,
|
|
23
|
+
accountId: OWNER.accountId,
|
|
24
|
+
accountConfigId: OWNER.accountConfigId,
|
|
25
|
+
filename: `${outboxAttachmentId}.txt`,
|
|
26
|
+
contentType: "text/plain",
|
|
27
|
+
sizeBytes: 5,
|
|
28
|
+
state: "Stored",
|
|
29
|
+
storageKey: `key/${outboxAttachmentId}`,
|
|
30
|
+
reservationExpiresAt: 0,
|
|
31
|
+
createdAt: 0,
|
|
32
|
+
updatedAt: 0,
|
|
33
|
+
...overrides,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const setup = async (rows: OutboxAttachmentItem[], stored: string[]) => {
|
|
37
|
+
const storage = createMockStorageService();
|
|
38
|
+
for (const outboxAttachmentId of stored) {
|
|
39
|
+
await storage.storeOutboxAttachment({
|
|
40
|
+
...OWNER,
|
|
41
|
+
outboxAttachmentId,
|
|
42
|
+
content: Buffer.from(`bytes of ${outboxAttachmentId}`),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
attachments: { listByOutboxMessage: async () => rows },
|
|
47
|
+
storage,
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe("loadOutboxAttachmentContents", () => {
|
|
52
|
+
it("returns every stored file with its bytes, in the order it was attached", async () => {
|
|
53
|
+
const deps = await setup(
|
|
54
|
+
[row("second", { createdAt: 20 }), row("first", { createdAt: 10 })],
|
|
55
|
+
["first", "second"],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const contents = await loadOutboxAttachmentContents(deps, OWNER, NOW);
|
|
59
|
+
|
|
60
|
+
assert.deepEqual(
|
|
61
|
+
contents.map((item) => [
|
|
62
|
+
item.filename,
|
|
63
|
+
item.contentType,
|
|
64
|
+
item.content.toString(),
|
|
65
|
+
]),
|
|
66
|
+
[
|
|
67
|
+
["first.txt", "text/plain", "bytes of first"],
|
|
68
|
+
["second.txt", "text/plain", "bytes of second"],
|
|
69
|
+
],
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("leaves out a reservation that lapsed without its bytes", async () => {
|
|
74
|
+
const deps = await setup(
|
|
75
|
+
[
|
|
76
|
+
row("kept"),
|
|
77
|
+
row("lapsed", { state: "Pending", reservationExpiresAt: NOW - 1 }),
|
|
78
|
+
],
|
|
79
|
+
["kept"],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const contents = await loadOutboxAttachmentContents(deps, OWNER, NOW);
|
|
83
|
+
|
|
84
|
+
assert.deepEqual(
|
|
85
|
+
contents.map((item) => item.filename),
|
|
86
|
+
["kept.txt"],
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("refuses to build a message while an upload is still open", async () => {
|
|
91
|
+
const deps = await setup(
|
|
92
|
+
[row("open", { state: "Pending", reservationExpiresAt: NOW + 60 })],
|
|
93
|
+
[],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await assert.rejects(
|
|
97
|
+
() => loadOutboxAttachmentContents(deps, OWNER, NOW),
|
|
98
|
+
(error: unknown) => {
|
|
99
|
+
assert.ok(error instanceof OutboxAttachmentUnavailableError);
|
|
100
|
+
assert.equal(error.filename, "open.txt");
|
|
101
|
+
assert.match(error.message, /never finished uploading/);
|
|
102
|
+
return true;
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("refuses to build a message whose stored file has no bytes behind it", async () => {
|
|
108
|
+
const deps = await setup([row("gone")], []);
|
|
109
|
+
|
|
110
|
+
await assert.rejects(
|
|
111
|
+
() => loadOutboxAttachmentContents(deps, OWNER, NOW),
|
|
112
|
+
(error: unknown) => {
|
|
113
|
+
assert.ok(error instanceof OutboxAttachmentUnavailableError);
|
|
114
|
+
assert.equal(
|
|
115
|
+
error.message,
|
|
116
|
+
'"gone.txt" could not be read: nothing is stored at key/gone',
|
|
117
|
+
);
|
|
118
|
+
return true;
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("names the file when storage itself fails", async () => {
|
|
124
|
+
const deps = {
|
|
125
|
+
attachments: { listByOutboxMessage: async () => [row("payroll")] },
|
|
126
|
+
storage: {
|
|
127
|
+
retrieveOutboxAttachment: async (): Promise<Buffer | null> => {
|
|
128
|
+
throw new Error("S3 is down");
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
await assert.rejects(
|
|
134
|
+
() => loadOutboxAttachmentContents(deps, OWNER, NOW),
|
|
135
|
+
(error: unknown) => {
|
|
136
|
+
assert.ok(error instanceof OutboxAttachmentUnavailableError);
|
|
137
|
+
assert.equal(
|
|
138
|
+
error.message,
|
|
139
|
+
'"payroll.txt" could not be read: storage failed (S3 is down)',
|
|
140
|
+
);
|
|
141
|
+
return true;
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { IOutboxAttachmentRepository } from "@remit/data-ports";
|
|
2
|
+
import { holdsRoom } from "@remit/data-ports";
|
|
3
|
+
import type { StorageService } from "@remit/storage-service";
|
|
4
|
+
|
|
5
|
+
export interface OutboxAttachmentContent {
|
|
6
|
+
filename: string;
|
|
7
|
+
contentType: string;
|
|
8
|
+
content: Buffer;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A file on a message that cannot be put into it. Carries the file's name so
|
|
13
|
+
* whoever settles the message can tell the sender which file to fix.
|
|
14
|
+
*/
|
|
15
|
+
export class OutboxAttachmentUnavailableError extends Error {
|
|
16
|
+
constructor(
|
|
17
|
+
readonly filename: string,
|
|
18
|
+
readonly detail: string,
|
|
19
|
+
options?: { cause: unknown },
|
|
20
|
+
) {
|
|
21
|
+
super(`"${filename}" could not be read: ${detail}`, options);
|
|
22
|
+
this.name = "OutboxAttachmentUnavailableError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const describeCause = (error: unknown): string =>
|
|
27
|
+
error instanceof Error ? error.message : String(error);
|
|
28
|
+
|
|
29
|
+
export interface OutboxAttachmentContentDeps {
|
|
30
|
+
attachments: Pick<IOutboxAttachmentRepository, "listByOutboxMessage">;
|
|
31
|
+
storage: Pick<StorageService, "retrieveOutboxAttachment">;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface OutboxAttachmentOwner {
|
|
35
|
+
accountConfigId: string;
|
|
36
|
+
accountId: string;
|
|
37
|
+
outboxMessageId: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Every file a message goes out with, bytes included, in the order they were
|
|
42
|
+
* attached. The send op refuses a draft with an upload still open, so a live
|
|
43
|
+
* `Pending` row here, or a `Stored` row with nothing behind it, is a message
|
|
44
|
+
* that cannot be built as the sender saw it — and sending it without the file
|
|
45
|
+
* would be worse than not sending it.
|
|
46
|
+
*/
|
|
47
|
+
export const loadOutboxAttachmentContents = async (
|
|
48
|
+
deps: OutboxAttachmentContentDeps,
|
|
49
|
+
owner: OutboxAttachmentOwner,
|
|
50
|
+
nowSeconds: number,
|
|
51
|
+
): Promise<OutboxAttachmentContent[]> => {
|
|
52
|
+
const held = await deps.attachments.listByOutboxMessage(
|
|
53
|
+
owner.accountConfigId,
|
|
54
|
+
owner.outboxMessageId,
|
|
55
|
+
);
|
|
56
|
+
const live = held
|
|
57
|
+
.filter((item) => holdsRoom(item, nowSeconds))
|
|
58
|
+
.sort((a, b) => a.createdAt - b.createdAt);
|
|
59
|
+
|
|
60
|
+
const unfinished = live.find((item) => item.state !== "Stored");
|
|
61
|
+
if (unfinished) {
|
|
62
|
+
throw new OutboxAttachmentUnavailableError(
|
|
63
|
+
unfinished.filename,
|
|
64
|
+
`attachment ${unfinished.outboxAttachmentId} never finished uploading`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return Promise.all(
|
|
69
|
+
live.map(async (item) => {
|
|
70
|
+
const content = await Promise.resolve()
|
|
71
|
+
.then(() =>
|
|
72
|
+
deps.storage.retrieveOutboxAttachment(
|
|
73
|
+
owner.accountConfigId,
|
|
74
|
+
owner.accountId,
|
|
75
|
+
owner.outboxMessageId,
|
|
76
|
+
item.outboxAttachmentId,
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
.catch((error: unknown) => {
|
|
80
|
+
throw new OutboxAttachmentUnavailableError(
|
|
81
|
+
item.filename,
|
|
82
|
+
`storage failed (${describeCause(error)})`,
|
|
83
|
+
{ cause: error },
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
if (content === null) {
|
|
87
|
+
throw new OutboxAttachmentUnavailableError(
|
|
88
|
+
item.filename,
|
|
89
|
+
`nothing is stored at ${item.storageKey}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
filename: item.filename,
|
|
94
|
+
contentType: item.contentType,
|
|
95
|
+
content,
|
|
96
|
+
};
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
};
|
package/src/outbox-attachment.ts
CHANGED
|
@@ -13,6 +13,10 @@ import {
|
|
|
13
13
|
buildOutboxAttachmentKey,
|
|
14
14
|
UPLOAD_URL_TTL_SECONDS,
|
|
15
15
|
} from "@remit/storage-service";
|
|
16
|
+
import {
|
|
17
|
+
loadOutboxAttachmentContents,
|
|
18
|
+
type OutboxAttachmentContent,
|
|
19
|
+
} from "./outbox-attachment-content.js";
|
|
16
20
|
import {
|
|
17
21
|
normalizeAttachmentContentType,
|
|
18
22
|
sanitizeAttachmentFilename,
|
|
@@ -407,6 +411,34 @@ export class OutboxAttachmentService {
|
|
|
407
411
|
return held.filter((item) => holdsRoom(item, nowSeconds));
|
|
408
412
|
};
|
|
409
413
|
|
|
414
|
+
/**
|
|
415
|
+
* The files a message goes out with, bytes included. The Sent copy is built
|
|
416
|
+
* from the same rows the wire copy was, so the two carry the same parts.
|
|
417
|
+
*/
|
|
418
|
+
contentsFor = (
|
|
419
|
+
accountConfigId: string,
|
|
420
|
+
accountId: string,
|
|
421
|
+
outboxMessageId: string,
|
|
422
|
+
): Promise<OutboxAttachmentContent[]> =>
|
|
423
|
+
loadOutboxAttachmentContents(
|
|
424
|
+
{ attachments: this.attachments, storage: this.storage },
|
|
425
|
+
{ accountConfigId, accountId, outboxMessageId },
|
|
426
|
+
this.now(),
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The file that stops a send, if any: one still uploading, or one whose
|
|
431
|
+
* upload never finished. A message must not leave without a file its sender
|
|
432
|
+
* can see in the composer.
|
|
433
|
+
*/
|
|
434
|
+
unfinishedUpload = async (
|
|
435
|
+
accountConfigId: string,
|
|
436
|
+
outboxMessageId: string,
|
|
437
|
+
): Promise<OutboxAttachmentItem | undefined> => {
|
|
438
|
+
const live = await this.listFor(accountConfigId, outboxMessageId);
|
|
439
|
+
return live.find((item) => item.state !== "Stored");
|
|
440
|
+
};
|
|
441
|
+
|
|
410
442
|
/**
|
|
411
443
|
* Drop the draft's lapsed reservations and answer with the ids that are still
|
|
412
444
|
* good. The sweep runs this before it decides what to collect: a lapsed row
|
|
@@ -122,7 +122,9 @@ const createHarness = (
|
|
|
122
122
|
|
|
123
123
|
harness.service = new OutboxQueueService({
|
|
124
124
|
outboxMessageService,
|
|
125
|
-
outboxAttachmentService: {
|
|
125
|
+
outboxAttachmentService: {
|
|
126
|
+
unfinishedUpload: async () => undefined,
|
|
127
|
+
} as unknown as OutboxAttachmentService,
|
|
126
128
|
accountService: {} as unknown as IAccountRepository,
|
|
127
129
|
sqsSmtpQueueUrl: "http://localhost/queue",
|
|
128
130
|
sqsClient: {
|
|
@@ -87,6 +87,7 @@ const createHarness = (stored: OutboxMessageItem): Harness => {
|
|
|
87
87
|
discardAll: async (_configId: string, _accountId: string, id: string) => {
|
|
88
88
|
harness.discarded.push(id);
|
|
89
89
|
},
|
|
90
|
+
unfinishedUpload: async () => undefined,
|
|
90
91
|
} as unknown as OutboxAttachmentService,
|
|
91
92
|
accountService: {} as unknown as IAccountRepository,
|
|
92
93
|
sqsSmtpQueueUrl: "http://localhost/queue",
|
package/src/outbox-queue.test.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { describe, it } from "node:test";
|
|
|
15
15
|
import type {
|
|
16
16
|
IAccountRepository,
|
|
17
17
|
IOutboxMessageRepository,
|
|
18
|
+
OutboxAttachmentItem,
|
|
18
19
|
OutboxMessageItem,
|
|
19
20
|
} from "@remit/data-ports";
|
|
20
21
|
import { BadRequestError } from "@remit/data-ports/errors";
|
|
@@ -50,7 +51,12 @@ interface Harness {
|
|
|
50
51
|
statusWrites: string[];
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
const createHarness = (
|
|
54
|
+
const createHarness = (
|
|
55
|
+
stored: OutboxMessageItem,
|
|
56
|
+
attachments: OutboxAttachmentItem[] = [],
|
|
57
|
+
lateAttachments: OutboxAttachmentItem[] = [],
|
|
58
|
+
): Harness => {
|
|
59
|
+
let unfinishedChecks = 0;
|
|
54
60
|
const harness: Harness = {
|
|
55
61
|
service: undefined as unknown as OutboxQueueService,
|
|
56
62
|
enqueued: [],
|
|
@@ -85,7 +91,13 @@ const createHarness = (stored: OutboxMessageItem): Harness => {
|
|
|
85
91
|
|
|
86
92
|
harness.service = new OutboxQueueService({
|
|
87
93
|
outboxMessageService,
|
|
88
|
-
outboxAttachmentService: {
|
|
94
|
+
outboxAttachmentService: {
|
|
95
|
+
unfinishedUpload: async () => {
|
|
96
|
+
unfinishedChecks += 1;
|
|
97
|
+
const held = unfinishedChecks === 1 ? attachments : lateAttachments;
|
|
98
|
+
return held.find((item) => item.state !== "Stored");
|
|
99
|
+
},
|
|
100
|
+
} as unknown as OutboxAttachmentService,
|
|
89
101
|
accountService: {} as unknown as IAccountRepository,
|
|
90
102
|
sqsSmtpQueueUrl: "http://localhost/queue",
|
|
91
103
|
sqsClient: {
|
|
@@ -99,6 +111,21 @@ const createHarness = (stored: OutboxMessageItem): Harness => {
|
|
|
99
111
|
return harness;
|
|
100
112
|
};
|
|
101
113
|
|
|
114
|
+
const pendingFile: OutboxAttachmentItem = {
|
|
115
|
+
outboxAttachmentId: "att-late",
|
|
116
|
+
outboxMessageId: OUTBOX_MESSAGE_ID,
|
|
117
|
+
accountId: ACCOUNT_ID,
|
|
118
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
119
|
+
filename: "report.pdf",
|
|
120
|
+
contentType: "application/pdf",
|
|
121
|
+
sizeBytes: 10,
|
|
122
|
+
state: "Pending",
|
|
123
|
+
storageKey: "k",
|
|
124
|
+
reservationExpiresAt: Number.MAX_SAFE_INTEGER,
|
|
125
|
+
createdAt: 0,
|
|
126
|
+
updatedAt: 0,
|
|
127
|
+
};
|
|
128
|
+
|
|
102
129
|
const sendInput = (overrides: Record<string, unknown>) => ({
|
|
103
130
|
accountId: ACCOUNT_ID,
|
|
104
131
|
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
@@ -126,6 +153,56 @@ describe("OutboxQueueService and a message with nowhere to go", () => {
|
|
|
126
153
|
assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
|
|
127
154
|
});
|
|
128
155
|
|
|
156
|
+
it("puts the draft back when a reservation lands between the check and the queue", async () => {
|
|
157
|
+
const harness = createHarness(draft({}), [], [pendingFile]);
|
|
158
|
+
|
|
159
|
+
await assert.rejects(
|
|
160
|
+
() => harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
|
|
161
|
+
(error: unknown) => {
|
|
162
|
+
assert.ok(error instanceof BadRequestError);
|
|
163
|
+
assert.match(error.message, /"report\.pdf" has not finished uploading/);
|
|
164
|
+
return true;
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
assert.deepEqual(harness.statusWrites, [
|
|
169
|
+
OutboxMessageStatus.queued,
|
|
170
|
+
OutboxMessageStatus.draft,
|
|
171
|
+
]);
|
|
172
|
+
assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("refuses to queue a draft with a file still uploading, and names the file", async () => {
|
|
176
|
+
const harness = createHarness(draft({}), [
|
|
177
|
+
{
|
|
178
|
+
outboxAttachmentId: "att-1",
|
|
179
|
+
outboxMessageId: OUTBOX_MESSAGE_ID,
|
|
180
|
+
accountId: ACCOUNT_ID,
|
|
181
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
182
|
+
filename: "report.pdf",
|
|
183
|
+
contentType: "application/pdf",
|
|
184
|
+
sizeBytes: 10,
|
|
185
|
+
state: "Pending",
|
|
186
|
+
storageKey: "k",
|
|
187
|
+
reservationExpiresAt: Number.MAX_SAFE_INTEGER,
|
|
188
|
+
createdAt: 0,
|
|
189
|
+
updatedAt: 0,
|
|
190
|
+
},
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
await assert.rejects(
|
|
194
|
+
() => harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
|
|
195
|
+
(error: unknown) => {
|
|
196
|
+
assert.ok(error instanceof BadRequestError);
|
|
197
|
+
assert.match(error.message, /"report\.pdf" has not finished uploading/);
|
|
198
|
+
return true;
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
assert.deepEqual(harness.statusWrites, [], "it stayed a draft");
|
|
203
|
+
assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
|
|
204
|
+
});
|
|
205
|
+
|
|
129
206
|
it("queues a draft addressed only in Bcc — that envelope is real", async () => {
|
|
130
207
|
const harness = createHarness(
|
|
131
208
|
draft({ toAddresses: [], bccAddresses: ["them@example.com"] }),
|
package/src/outbox-queue.ts
CHANGED
|
@@ -269,6 +269,16 @@ export class OutboxQueueService {
|
|
|
269
269
|
throw new BadRequestError(NO_RECIPIENT_MESSAGE);
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
const unfinished = await this.outboxAttachmentService.unfinishedUpload(
|
|
273
|
+
accountConfigId,
|
|
274
|
+
outboxMessageId,
|
|
275
|
+
);
|
|
276
|
+
if (unfinished) {
|
|
277
|
+
throw new BadRequestError(
|
|
278
|
+
`"${unfinished.filename}" has not finished uploading. Wait for it, or remove it, and send again.`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
272
282
|
// Conditional on the status this send was decided against, so two presses —
|
|
273
283
|
// or a press racing the worker — produce one queued row and one conflict
|
|
274
284
|
// rather than two events for the same message.
|
|
@@ -280,6 +290,25 @@ export class OutboxQueueService {
|
|
|
280
290
|
);
|
|
281
291
|
if (!updated) throw new ConflictError(MOVED_WHILE_SENDING_MESSAGE);
|
|
282
292
|
|
|
293
|
+
// A reservation made from another tab between the check above and the
|
|
294
|
+
// move to `queued` got past it; one made after the move is refused, so a
|
|
295
|
+
// second look now is the last word.
|
|
296
|
+
const lateUpload = await this.outboxAttachmentService.unfinishedUpload(
|
|
297
|
+
accountConfigId,
|
|
298
|
+
outboxMessageId,
|
|
299
|
+
);
|
|
300
|
+
if (lateUpload) {
|
|
301
|
+
await this.outboxMessageService.updateIfStatus(
|
|
302
|
+
accountConfigId,
|
|
303
|
+
outboxMessageId,
|
|
304
|
+
OutboxMessageStatus.queued,
|
|
305
|
+
{ status: existing.status },
|
|
306
|
+
);
|
|
307
|
+
throw new BadRequestError(
|
|
308
|
+
`"${lateUpload.filename}" has not finished uploading. Wait for it, or remove it, and send again.`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
283
312
|
// `queued` is a dead end for a row the queue never accepted: `send` takes
|
|
284
313
|
// draft, failed and blocked, `deleteDraft` those three plus unfiled, so a
|
|
285
314
|
// row parked at `queued` by a failed enqueue is neither sendable nor
|