@remit/mailbox-service 0.0.104 → 0.0.106
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/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-failed-recovery.test.ts +3 -1
- package/src/outbox-queue-settled-row.test.ts +3 -1
- package/src/outbox-queue.test.ts +50 -2
- package/src/outbox-queue.ts +17 -2
package/package.json
CHANGED
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
|
|
@@ -125,7 +125,9 @@ const createHarness = (
|
|
|
125
125
|
outboxAttachmentService: {
|
|
126
126
|
unfinishedUpload: async () => undefined,
|
|
127
127
|
} as unknown as OutboxAttachmentService,
|
|
128
|
-
accountService: {
|
|
128
|
+
accountService: {
|
|
129
|
+
get: async () => ({ syncedServices: ["Mail"] }),
|
|
130
|
+
} as unknown as IAccountRepository,
|
|
129
131
|
sqsSmtpQueueUrl: "http://localhost/queue",
|
|
130
132
|
sqsClient: {
|
|
131
133
|
send: async (command: { input: { MessageBody: string } }) => {
|
|
@@ -89,7 +89,9 @@ const createHarness = (stored: OutboxMessageItem): Harness => {
|
|
|
89
89
|
},
|
|
90
90
|
unfinishedUpload: async () => undefined,
|
|
91
91
|
} as unknown as OutboxAttachmentService,
|
|
92
|
-
accountService: {
|
|
92
|
+
accountService: {
|
|
93
|
+
get: async () => ({ syncedServices: ["Mail"] }),
|
|
94
|
+
} as unknown as IAccountRepository,
|
|
93
95
|
sqsSmtpQueueUrl: "http://localhost/queue",
|
|
94
96
|
sqsClient: {
|
|
95
97
|
send: async (command: { input: { MessageBody: string } }) => {
|
package/src/outbox-queue.test.ts
CHANGED
|
@@ -18,7 +18,7 @@ import type {
|
|
|
18
18
|
OutboxAttachmentItem,
|
|
19
19
|
OutboxMessageItem,
|
|
20
20
|
} from "@remit/data-ports";
|
|
21
|
-
import { BadRequestError } from "@remit/data-ports/errors";
|
|
21
|
+
import { BadRequestError, MailSyncOffError } from "@remit/data-ports/errors";
|
|
22
22
|
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
23
23
|
import type { OutboxAttachmentService } from "./outbox-attachment.js";
|
|
24
24
|
import { OutboxQueueService } from "./outbox-queue.js";
|
|
@@ -55,6 +55,7 @@ const createHarness = (
|
|
|
55
55
|
stored: OutboxMessageItem,
|
|
56
56
|
attachments: OutboxAttachmentItem[] = [],
|
|
57
57
|
lateAttachments: OutboxAttachmentItem[] = [],
|
|
58
|
+
syncedServices: string[] = ["Mail"],
|
|
58
59
|
): Harness => {
|
|
59
60
|
let unfinishedChecks = 0;
|
|
60
61
|
const harness: Harness = {
|
|
@@ -98,7 +99,9 @@ const createHarness = (
|
|
|
98
99
|
return held.find((item) => item.state !== "Stored");
|
|
99
100
|
},
|
|
100
101
|
} as unknown as OutboxAttachmentService,
|
|
101
|
-
accountService: {
|
|
102
|
+
accountService: {
|
|
103
|
+
get: async () => ({ syncedServices }),
|
|
104
|
+
} as unknown as IAccountRepository,
|
|
102
105
|
sqsSmtpQueueUrl: "http://localhost/queue",
|
|
103
106
|
sqsClient: {
|
|
104
107
|
send: async (command: { input: { MessageBody: string } }) => {
|
|
@@ -241,3 +244,48 @@ describe("OutboxQueueService and a message with nowhere to go", () => {
|
|
|
241
244
|
assert.equal(harness.enqueued.length, 1);
|
|
242
245
|
});
|
|
243
246
|
});
|
|
247
|
+
|
|
248
|
+
describe("OutboxQueueService and an account with mail off", () => {
|
|
249
|
+
const refusesWithTheFix = (error: unknown): boolean => {
|
|
250
|
+
assert.ok(error instanceof MailSyncOffError);
|
|
251
|
+
assert.equal(error.statusCode, 400);
|
|
252
|
+
assert.equal(error.publicApiError?.code, "mail_sync_off");
|
|
253
|
+
assert.match(error.message, /Turn Mail back on .* in Settings/);
|
|
254
|
+
return true;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
it("refuses to queue a stored draft and leaves it a draft", async () => {
|
|
258
|
+
const harness = createHarness(draft({}), [], [], ["Calendar"]);
|
|
259
|
+
|
|
260
|
+
await assert.rejects(
|
|
261
|
+
() => harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
|
|
262
|
+
refusesWithTheFix,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
assert.deepEqual(harness.statusWrites, []);
|
|
266
|
+
assert.deepEqual(harness.enqueued, []);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("refuses a send-immediately create before writing a row", async () => {
|
|
270
|
+
const harness = createHarness(draft({}), [], [], ["Calendar"]);
|
|
271
|
+
|
|
272
|
+
await assert.rejects(
|
|
273
|
+
() =>
|
|
274
|
+
harness.service.createAndSend(
|
|
275
|
+
sendInput({ toAddresses: ["them@example.com"] }),
|
|
276
|
+
),
|
|
277
|
+
refusesWithTheFix,
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
assert.equal(harness.created, 0);
|
|
281
|
+
assert.deepEqual(harness.enqueued, []);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("sends from an account syncing mail and calendar", async () => {
|
|
285
|
+
const harness = createHarness(draft({}), [], [], ["Mail", "Calendar"]);
|
|
286
|
+
|
|
287
|
+
await harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID);
|
|
288
|
+
|
|
289
|
+
assert.equal(harness.enqueued.length, 1);
|
|
290
|
+
});
|
|
291
|
+
});
|
package/src/outbox-queue.ts
CHANGED
|
@@ -5,8 +5,12 @@ import type {
|
|
|
5
5
|
IOutboxMessageRepository,
|
|
6
6
|
OutboxMessageItem,
|
|
7
7
|
} from "@remit/data-ports";
|
|
8
|
-
import {
|
|
9
|
-
|
|
8
|
+
import {
|
|
9
|
+
BadRequestError,
|
|
10
|
+
ConflictError,
|
|
11
|
+
MailSyncOffError,
|
|
12
|
+
} from "@remit/data-ports/errors";
|
|
13
|
+
import { AccountService, OutboxMessageStatus } from "@remit/domain-enums";
|
|
10
14
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
11
15
|
import type { OutboxAttachmentService } from "./outbox-attachment.js";
|
|
12
16
|
import { isOpenForWork } from "./outbox-status.js";
|
|
@@ -88,6 +92,9 @@ const hasNowhereToGo = (message: {
|
|
|
88
92
|
const NO_RECIPIENT_MESSAGE =
|
|
89
93
|
"This message has nobody to send to. Add a recipient before sending it.";
|
|
90
94
|
|
|
95
|
+
const MAIL_SYNC_OFF_SEND_MESSAGE =
|
|
96
|
+
"Mail is turned off for this account, so it cannot send. Turn Mail back on for this account in Settings to send from it.";
|
|
97
|
+
|
|
91
98
|
const ENQUEUE_FAILED_MESSAGE =
|
|
92
99
|
"This message could not be handed to the outgoing queue, so it was not sent. Send it again.";
|
|
93
100
|
|
|
@@ -250,6 +257,12 @@ export class OutboxQueueService {
|
|
|
250
257
|
return updated;
|
|
251
258
|
};
|
|
252
259
|
|
|
260
|
+
private refuseWithoutMail = async (accountId: string): Promise<void> => {
|
|
261
|
+
const account = await this.accountService.get(accountId);
|
|
262
|
+
if (account.syncedServices.includes(AccountService.Mail)) return;
|
|
263
|
+
throw new MailSyncOffError(MAIL_SYNC_OFF_SEND_MESSAGE, accountId);
|
|
264
|
+
};
|
|
265
|
+
|
|
253
266
|
send = async (
|
|
254
267
|
accountConfigId: string,
|
|
255
268
|
outboxMessageId: string,
|
|
@@ -268,6 +281,7 @@ export class OutboxQueueService {
|
|
|
268
281
|
if (hasNowhereToGo(existing)) {
|
|
269
282
|
throw new BadRequestError(NO_RECIPIENT_MESSAGE);
|
|
270
283
|
}
|
|
284
|
+
await this.refuseWithoutMail(existing.accountId);
|
|
271
285
|
|
|
272
286
|
const unfinished = await this.outboxAttachmentService.unfinishedUpload(
|
|
273
287
|
accountConfigId,
|
|
@@ -339,6 +353,7 @@ export class OutboxQueueService {
|
|
|
339
353
|
if (hasNowhereToGo(input)) {
|
|
340
354
|
throw new BadRequestError(NO_RECIPIENT_MESSAGE);
|
|
341
355
|
}
|
|
356
|
+
await this.refuseWithoutMail(input.accountId);
|
|
342
357
|
|
|
343
358
|
const domain = extractDomain(input.fromAddress);
|
|
344
359
|
const messageIdValue = generateMessageId(domain);
|