@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.104",
3
+ "version": "0.0.106",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -219,6 +219,8 @@ export {
219
219
  FolderRenameSettleError,
220
220
  isMailboxAbsentUpstream,
221
221
  isMailboxPresentUpstream,
222
+ isMailboxRefusedUpstream,
223
+ upstreamFailureReason,
222
224
  } from "./mailbox-upstream.js";
223
225
  export {
224
226
  denormalizeMessageCategory,
@@ -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 (accountId: string, mailboxId: string): Promise<void> => {
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("acc-1", "mbx-parent", "Work", "Projects");
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("acc-1", "mbx-parent", "Work", "Projects");
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("acc-1", "mbx-parent", "Work", "Projects"),
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("acc-1", "mbx-parent", "Work", "Projects"),
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("acc-1", "mbx-parent", "Work", "Projects");
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
  });
@@ -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: {} as unknown as IAccountRepository,
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: {} as unknown as IAccountRepository,
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 } }) => {
@@ -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: {} as unknown as IAccountRepository,
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
+ });
@@ -5,8 +5,12 @@ import type {
5
5
  IOutboxMessageRepository,
6
6
  OutboxMessageItem,
7
7
  } from "@remit/data-ports";
8
- import { BadRequestError, ConflictError } from "@remit/data-ports/errors";
9
- import { OutboxMessageStatus } from "@remit/domain-enums";
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);