@remit/mailbox-service 0.0.102 → 0.0.104
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/body-sync-auto-read-writeonce.test.ts +3 -3
- package/src/body-sync-calendar-suggestion.test.ts +1 -1
- package/src/body-sync-category-denormalize.test.ts +2 -2
- package/src/body-sync-category-override.test.ts +9 -9
- package/src/body-sync-category-writeonce.test.ts +3 -3
- package/src/body-sync-filter.test.ts +1 -1
- package/src/body-sync-placement-flags.test.ts +1 -1
- package/src/body-sync-placement-writeonce.test.ts +4 -4
- package/src/body-sync-quarantine.test.ts +7 -3
- package/src/body-sync-resync.test.ts +5 -5
- package/src/body-sync-unclassifiable-once.test.ts +6 -2
- package/src/body-sync-unsubscribed-auto-read.test.ts +1 -1
- package/src/body-sync.ts +45 -21
- package/src/classification-backfill.test.ts +72 -0
- package/src/classification-backfill.ts +18 -0
- package/src/classify-message.test.ts +59 -0
- package/src/classify-message.ts +5 -2
- package/src/folder-placement.ts +56 -0
- package/src/heuristics/senderMismatch.test.ts +44 -6
- package/src/heuristics/senderMismatch.ts +10 -4
- package/src/index.ts +6 -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/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
|