@remit/mailbox-service 0.0.59 → 0.0.61

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.59",
3
+ "version": "0.0.61",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -19,7 +19,6 @@ import type {
19
19
  IThreadMessageRepository,
20
20
  UpdateMessageInput,
21
21
  } from "@remit/data-ports";
22
- import { MailboxSpecialUse } from "@remit/domain-enums";
23
22
  import type { StorageService } from "@remit/storage-service";
24
23
  import type { PlacementConfig } from "./body-sync.js";
25
24
  import { BodySyncService } from "./body-sync.js";
@@ -116,11 +115,8 @@ const buildHarness = (
116
115
  } as unknown as IEnvelopeRepository;
117
116
 
118
117
  const mailboxSpecialUseService = {
119
- findBySpecialUse: async (_accountId: string, specialUse: string) => {
120
- if (specialUse === MailboxSpecialUse.Junk) return MAILBOXES.junk;
121
- if (specialUse === MailboxSpecialUse.Archive) return MAILBOXES.archive;
122
- return null;
123
- },
118
+ findJunkMailbox: async () => MAILBOXES.junk,
119
+ findArchiveMailbox: async () => MAILBOXES.archive,
124
120
  findInboxMailbox: async () => MAILBOXES.inbox,
125
121
  } as unknown as IMailboxSpecialUseRepository;
126
122
 
@@ -30,7 +30,6 @@ import type {
30
30
  MessageItem,
31
31
  UpdateMessageInput,
32
32
  } from "@remit/data-ports";
33
- import { MailboxSpecialUse } from "@remit/domain-enums";
34
33
  import type { StorageService } from "@remit/storage-service";
35
34
  import type { PlacementConfig } from "./body-sync.js";
36
35
  import { BodySyncService } from "./body-sync.js";
@@ -129,8 +128,8 @@ const buildHarness = (
129
128
  } as unknown as IEnvelopeRepository;
130
129
 
131
130
  const mailboxSpecialUseService = {
132
- findBySpecialUse: async (_accountId: string, specialUse: string) =>
133
- specialUse === MailboxSpecialUse.Junk ? MAILBOXES.junk : null,
131
+ findJunkMailbox: async () => MAILBOXES.junk,
132
+ findArchiveMailbox: async () => null,
134
133
  findInboxMailbox: async () => MAILBOXES.inbox,
135
134
  } as unknown as IMailboxSpecialUseRepository;
136
135
 
package/src/body-sync.ts CHANGED
@@ -14,7 +14,6 @@ import { NotFoundError } from "@remit/data-ports/errors";
14
14
  import { deriveAddressId } from "@remit/data-ports/id";
15
15
  import { isBulkSender } from "@remit/data-ports/wellknown";
16
16
  import {
17
- MailboxSpecialUse,
18
17
  MessageCategory,
19
18
  PlacementAction,
20
19
  PlacementConfidence,
@@ -1477,10 +1476,8 @@ export class BodySyncService {
1477
1476
  // against the same signals that placed it in the first place.
1478
1477
  if (hasDecidedPlacement(message.placementDecidedAt)) return {};
1479
1478
 
1480
- const junkMailbox = await mailboxSpecialUseService.findBySpecialUse(
1481
- accountId,
1482
- MailboxSpecialUse.Junk,
1483
- );
1479
+ const junkMailbox =
1480
+ await mailboxSpecialUseService.findJunkMailbox(accountId);
1484
1481
  const inboxMailbox =
1485
1482
  await mailboxSpecialUseService.findInboxMailbox(accountId);
1486
1483
 
@@ -1612,10 +1609,8 @@ export class BodySyncService {
1612
1609
  ): Promise<PlacementOutcome> {
1613
1610
  if (!autoArchive) return {};
1614
1611
 
1615
- const archiveMailbox = await mailboxSpecialUseService.findBySpecialUse(
1616
- accountId,
1617
- MailboxSpecialUse.Archive,
1618
- );
1612
+ const archiveMailbox =
1613
+ await mailboxSpecialUseService.findArchiveMailbox(accountId);
1619
1614
  if (!archiveMailbox || message.mailboxId === archiveMailbox.mailboxId) {
1620
1615
  return {};
1621
1616
  }
package/src/index.ts CHANGED
@@ -185,6 +185,7 @@ export {
185
185
  type MessageMoveConfig,
186
186
  type MessageMoveLogger,
187
187
  MessageMoveService,
188
+ NoTrashMailboxError,
188
189
  } from "./message-move.js";
189
190
  export {
190
191
  type ParsedMessageContent,
@@ -98,10 +98,12 @@ describe("deleting a message re-asks what its senders stand on", () => {
98
98
  assert.deepEqual(reconciled, []);
99
99
  });
100
100
 
101
- it("asks nothing when the account has no Trash folder", async () => {
101
+ it("asks nothing when the account has no Trash folder, because the delete is refused", async () => {
102
102
  const { service, reconciled } = buildWorld(false);
103
103
 
104
- await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
104
+ await assert.rejects(() =>
105
+ service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT),
106
+ );
105
107
 
106
108
  assert.deepEqual(reconciled, []);
107
109
  });
@@ -0,0 +1,168 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type {
4
+ IAddressRepository,
5
+ IMailboxRepository,
6
+ IMailboxSpecialUseRepository,
7
+ IMessageRepository,
8
+ IThreadMessageRepository,
9
+ } from "@remit/data-ports";
10
+ import {
11
+ type MessageMoveConfig,
12
+ MessageMoveService,
13
+ NoTrashMailboxError,
14
+ } from "./message-move.js";
15
+
16
+ const ACCOUNT = "acc-1";
17
+ const ACCOUNT_CONFIG = "cfg-1";
18
+ const INBOX = "mbx-inbox";
19
+ const TRASH = "mbx-trash";
20
+ const MESSAGE_ID = "msg-1";
21
+
22
+ interface EnqueuedEvent {
23
+ operation: string;
24
+ destinationMailboxPath?: string;
25
+ }
26
+
27
+ const buildWorld = (
28
+ trash: { mailboxId: string; fullPath: string } | null,
29
+ startingMailboxId = INBOX,
30
+ ) => {
31
+ const patches: Array<Record<string, unknown>> = [];
32
+ const events: EnqueuedEvent[] = [];
33
+ const threadMessageDeletes: string[] = [];
34
+
35
+ const message = {
36
+ messageId: MESSAGE_ID,
37
+ mailboxId: startingMailboxId,
38
+ uid: 337,
39
+ status: "active",
40
+ syncStatus: "synced",
41
+ };
42
+
43
+ const messageService = {
44
+ get: async () => [message],
45
+ update: async (_id: string, patch: Record<string, unknown>) => {
46
+ patches.push(patch);
47
+ return Object.assign(message, patch);
48
+ },
49
+ updateForMove: async (_id: string, patch: Record<string, unknown>) => {
50
+ patches.push(patch);
51
+ return Object.assign(message, patch);
52
+ },
53
+ } as unknown as IMessageRepository;
54
+
55
+ const threadMessageService = {
56
+ findAllByMessageId: async () => [],
57
+ getByMessageId: async () => ({
58
+ accountConfigId: ACCOUNT_CONFIG,
59
+ threadMessageId: "tm-1",
60
+ messageId: MESSAGE_ID,
61
+ mailboxId: startingMailboxId,
62
+ }),
63
+ update: async () => {},
64
+ delete: async (_cfg: string, id: string) => {
65
+ threadMessageDeletes.push(id);
66
+ },
67
+ } as unknown as IThreadMessageRepository;
68
+
69
+ const mailboxService = {
70
+ get: async () => [
71
+ { mailboxId: INBOX, fullPath: "INBOX", accountId: ACCOUNT },
72
+ { mailboxId: TRASH, fullPath: "INBOX/Trash", accountId: ACCOUNT },
73
+ ],
74
+ } as unknown as IMailboxRepository;
75
+
76
+ const mailboxSpecialUseService = {
77
+ findTrashMailbox: async () => trash,
78
+ } as unknown as IMailboxSpecialUseRepository;
79
+
80
+ const addressService = {
81
+ reconcileJunkOnlyForMessage: async () => {},
82
+ } as unknown as IAddressRepository;
83
+
84
+ const config: MessageMoveConfig = {
85
+ messageService,
86
+ mailboxService,
87
+ mailboxSpecialUseService,
88
+ threadMessageService,
89
+ addressService,
90
+ sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
91
+ };
92
+
93
+ const service = new MessageMoveService(config);
94
+ (
95
+ service as unknown as {
96
+ enqueueEventsBatch: (batch: EnqueuedEvent[]) => Promise<void>;
97
+ }
98
+ ).enqueueEventsBatch = async (batch) => {
99
+ events.push(...batch);
100
+ };
101
+
102
+ return { service, patches, events, message };
103
+ };
104
+
105
+ describe("delete only expunges when it was asked to", () => {
106
+ it("moves to a Trash the account resolves under a nested path", async () => {
107
+ const { service, events, message } = buildWorld({
108
+ mailboxId: TRASH,
109
+ fullPath: "INBOX/Trash",
110
+ });
111
+
112
+ await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
113
+
114
+ assert.deepEqual(
115
+ events.map((event) => event.operation),
116
+ ["move_to_trash"],
117
+ );
118
+ assert.equal(events[0].destinationMailboxPath, "INBOX/Trash");
119
+ assert.equal(message.mailboxId, TRASH);
120
+ });
121
+
122
+ it("refuses, and touches nothing, when no Trash resolves", async () => {
123
+ const { service, patches, events, message } = buildWorld(null);
124
+
125
+ await assert.rejects(
126
+ () => service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT),
127
+ (error: unknown) =>
128
+ error instanceof NoTrashMailboxError &&
129
+ error.statusCode === 409 &&
130
+ error.message.includes("no Trash folder"),
131
+ );
132
+
133
+ assert.deepEqual(events, []);
134
+ assert.deepEqual(patches, []);
135
+ assert.equal(message.mailboxId, INBOX);
136
+ assert.equal(message.status, "active");
137
+ });
138
+
139
+ it("expunges only when the caller asked for a permanent delete", async () => {
140
+ const { service, events } = buildWorld({
141
+ mailboxId: TRASH,
142
+ fullPath: "INBOX/Trash",
143
+ });
144
+
145
+ await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT, {
146
+ permanent: true,
147
+ });
148
+
149
+ assert.deepEqual(
150
+ events.map((event) => event.operation),
151
+ ["permanent_delete"],
152
+ );
153
+ });
154
+
155
+ it("expunges a message already in Trash, which the dialog asks as such", async () => {
156
+ const { service, events } = buildWorld(
157
+ { mailboxId: TRASH, fullPath: "INBOX/Trash" },
158
+ TRASH,
159
+ );
160
+
161
+ await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
162
+
163
+ assert.deepEqual(
164
+ events.map((event) => event.operation),
165
+ ["permanent_delete"],
166
+ );
167
+ });
168
+ });
@@ -15,6 +15,7 @@ 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 { noFolderRoles } from "./test-helpers/folder-roles.js";
18
19
  import type { IImapConnection, ImapMessage } from "./types.js";
19
20
 
20
21
  const stubAddressService = (): IAddressRepository =>
@@ -304,6 +305,7 @@ const buildSyncOverDestination = (
304
305
  return new MessageSyncService(
305
306
  connectionFactory,
306
307
  mailboxService,
308
+ noFolderRoles,
307
309
  world.messageService,
308
310
  {} as IEnvelopeRepository,
309
311
  {} as IAddressRepository,
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Empty Trash is an EXPUNGE with no undo, so it resolves its folder through
3
+ * `findConfirmedTrashMailbox` — what the user appointed, or what the server
4
+ * flagged `\Trash`. The name proposal that serves every other lookup is a
5
+ * guess, and a wrong guess here destroys mail (#837, audit #841).
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { describe, it } from "node:test";
9
+ import type {
10
+ IAddressRepository,
11
+ IMailboxRepository,
12
+ IMailboxSpecialUseRepository,
13
+ IMessageRepository,
14
+ IThreadMessageRepository,
15
+ } from "@remit/data-ports";
16
+ import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
17
+
18
+ const ACCOUNT = "acc-1";
19
+ const ACCOUNT_CONFIG = "cfg-1";
20
+ const DELETED_FOLDER = "mbx-deleted";
21
+ const REAL_TRASH = "mbx-trash";
22
+
23
+ const buildWorld = (
24
+ confirmedTrash: { mailboxId: string; fullPath: string } | null,
25
+ ) => {
26
+ const emptied: string[] = [];
27
+ const messagesByMailbox = new Map<string, { messageId: string }[]>([
28
+ [DELETED_FOLDER, [{ messageId: "keepsake-1" }]],
29
+ [REAL_TRASH, [{ messageId: "junk-1" }]],
30
+ ]);
31
+
32
+ const messageService = {
33
+ listAllByMailbox: async (mailboxId: string) => {
34
+ emptied.push(mailboxId);
35
+ return messagesByMailbox.get(mailboxId) ?? [];
36
+ },
37
+ update: async () => {},
38
+ } as unknown as IMessageRepository;
39
+
40
+ const threadMessageService = {
41
+ getByMessageId: async () => ({
42
+ accountConfigId: ACCOUNT_CONFIG,
43
+ threadMessageId: "tm-1",
44
+ sentDate: 1_700_000_000_000,
45
+ }),
46
+ update: async () => {},
47
+ } as unknown as IThreadMessageRepository;
48
+
49
+ const config: MessageMoveConfig = {
50
+ messageService,
51
+ mailboxService: {} as unknown as IMailboxRepository,
52
+ addressService: {
53
+ reconcileJunkOnlyForMessage: async () => {},
54
+ } as unknown as IAddressRepository,
55
+ mailboxSpecialUseService: {
56
+ // A folder merely named `Deleted` is what the name proposal would
57
+ // return; the confirmed lookup is what this path asks for.
58
+ findTrashMailbox: async () => ({
59
+ mailboxId: DELETED_FOLDER,
60
+ fullPath: "Deleted",
61
+ }),
62
+ findConfirmedTrashMailbox: async () => confirmedTrash,
63
+ } as unknown as IMailboxSpecialUseRepository,
64
+ threadMessageService,
65
+ sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
66
+ };
67
+
68
+ const service = new MessageMoveService(config);
69
+ (
70
+ service as unknown as { sqs: { send: (c: unknown) => Promise<unknown> } }
71
+ ).sqs = { send: async () => ({}) };
72
+
73
+ return { service, emptied };
74
+ };
75
+
76
+ describe("MessageMoveService.emptyTrash", () => {
77
+ it("empties the appointed Trash, never the user folder called Deleted", async () => {
78
+ const { service, emptied } = buildWorld({
79
+ mailboxId: REAL_TRASH,
80
+ fullPath: "[Gmail]/Trash",
81
+ });
82
+
83
+ await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
84
+
85
+ assert.deepEqual(emptied, [REAL_TRASH]);
86
+ });
87
+
88
+ it("refuses and names the remedy when no folder is appointed or flagged", async () => {
89
+ const { service, emptied } = buildWorld(null);
90
+
91
+ await assert.rejects(
92
+ service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT),
93
+ /Appoint one under Settings/,
94
+ );
95
+
96
+ // Nothing was read, so nothing was marked for deletion and no expunge
97
+ // was enqueued: an unresolved Trash stops the operation dead.
98
+ assert.deepEqual(emptied, []);
99
+ });
100
+ });
@@ -11,6 +11,8 @@ import type {
11
11
  IMessageRepository,
12
12
  IThreadMessageRepository,
13
13
  } from "@remit/data-ports";
14
+ import { HTTPError } from "@remit/data-ports/errors";
15
+ import { NO_TRASH_FOLDER_REASON } from "@remit/data-ports/folder-role";
14
16
  import { deriveCopyMessageId } from "@remit/data-ports/id";
15
17
  import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
16
18
  import { createQueueProducer } from "@remit/sqs-client/producer";
@@ -103,6 +105,22 @@ export interface MessageMoveConfig {
103
105
  logger?: MessageMoveLogger;
104
106
  }
105
107
 
108
+ /**
109
+ * The account resolves no Trash folder, so a move-to-Trash delete has nowhere
110
+ * to put the mail and an Empty Trash has nothing it may empty. A designed,
111
+ * user-facing outcome carrying its own text and a 409 the backend returns
112
+ * verbatim: the absence of a Trash folder is a blocker the user resolves by
113
+ * appointing one, never a licence to expunge.
114
+ */
115
+ export class NoTrashMailboxError extends HTTPError {
116
+ name = "NoTrashMailboxError";
117
+ statusCode = 409;
118
+
119
+ constructor() {
120
+ super(NO_TRASH_FOLDER_REASON);
121
+ }
122
+ }
123
+
106
124
  /**
107
125
  * Options for delete operations
108
126
  */
@@ -191,9 +209,26 @@ export class MessageMoveService {
191
209
  );
192
210
  const mailboxMap = new Map(mailboxes.map((m) => [m.mailboxId, m]));
193
211
 
194
- // Find trash mailbox once
195
- const trashMailbox =
196
- await this.mailboxSpecialUseService.findTrashMailbox(accountId);
212
+ // A permanent delete is only ever what the caller explicitly asked for.
213
+ // Everything else is a move to Trash, and a Trash folder that does not
214
+ // resolve blocks it — the user was asked "move to Trash?" and expunging
215
+ // their mail instead is unrecoverable and answers a question nobody put
216
+ // to them. Thrown before any local write or event, so the server and the
217
+ // projection are both left untouched.
218
+ const wantsPermanentDelete =
219
+ options.permanent === true || options.toTrash === false;
220
+
221
+ const trashMailbox = wantsPermanentDelete
222
+ ? null
223
+ : await this.mailboxSpecialUseService.findTrashMailbox(accountId);
224
+
225
+ if (!wantsPermanentDelete && !trashMailbox) {
226
+ this.log.error(
227
+ { accountId, messageCount: messages.length },
228
+ "Refused to delete: account resolves no Trash mailbox",
229
+ );
230
+ throw new NoTrashMailboxError();
231
+ }
197
232
 
198
233
  // Group messages by operation type
199
234
  const moveToTrashMessages: Array<{
@@ -212,12 +247,8 @@ export class MessageMoveService {
212
247
  if (!sourceMailbox) continue;
213
248
 
214
249
  const isInTrash =
215
- trashMailbox && message.mailboxId === trashMailbox.mailboxId;
216
- const shouldMoveToTrash =
217
- options.toTrash !== false &&
218
- !options.permanent &&
219
- !isInTrash &&
220
- trashMailbox;
250
+ trashMailbox !== null && message.mailboxId === trashMailbox.mailboxId;
251
+ const shouldMoveToTrash = trashMailbox !== null && !isInTrash;
221
252
 
222
253
  const entry = {
223
254
  messageId: message.messageId,
@@ -228,7 +259,7 @@ export class MessageMoveService {
228
259
  },
229
260
  };
230
261
 
231
- if (shouldMoveToTrash && trashMailbox) {
262
+ if (shouldMoveToTrash) {
232
263
  moveToTrashMessages.push(entry);
233
264
  } else {
234
265
  permanentDeleteMessages.push(entry);
@@ -618,19 +649,25 @@ export class MessageMoveService {
618
649
  };
619
650
 
620
651
  /**
621
- * Empty the Trash mailbox (permanent delete all).
652
+ * Empty the Trash mailbox: an EXPUNGE of everything in it, with no undo.
622
653
  *
623
- * @param accountId - Account ID
654
+ * Resolved through `findConfirmedTrashMailbox`, so the folder is the one the
655
+ * user appointed or the one the server flagged \Trash — never one that
656
+ * merely reads like a trash folder. A user with an ordinary folder called
657
+ * `Deleted` would otherwise lose its contents permanently, and on a
658
+ * `.`-delimited server the old whole-path name match resolved nothing at all
659
+ * and the operation reported success over an empty count. Unresolved is a
660
+ * refusal that names the remedy, not a silent no-op.
624
661
  */
625
662
  emptyTrash = async (
626
663
  accountConfigId: string,
627
664
  accountId: string,
628
665
  ): Promise<void> => {
629
666
  const trashMailbox =
630
- await this.mailboxSpecialUseService.findTrashMailbox(accountId);
667
+ await this.mailboxSpecialUseService.findConfirmedTrashMailbox(accountId);
631
668
 
632
669
  if (!trashMailbox) {
633
- throw new Error("No Trash mailbox found for account");
670
+ throw new NoTrashMailboxError();
634
671
  }
635
672
 
636
673
  // Get all messages in Trash
@@ -14,7 +14,8 @@ import type {
14
14
  ThreadMessageItem,
15
15
  } from "@remit/data-ports";
16
16
  import type { ManagedConnectionFactory } from "./connection-factory.js";
17
- import { MessageSyncService } from "./message-sync.js";
17
+ import { type AccountFolderRoles, MessageSyncService } from "./message-sync.js";
18
+ import { NO_FOLDER_ROLES, noFolderRoles } from "./test-helpers/folder-roles.js";
18
19
  import type { ImapAddress, ImapEnvelope, ImapMessage } from "./types.js";
19
20
 
20
21
  const stub = <T>(): T => ({}) as T;
@@ -76,6 +77,7 @@ const harvest = async (envelope: Partial<ImapEnvelope>): Promise<Saved> => {
76
77
  const service = new MessageSyncService(
77
78
  stub<ManagedConnectionFactory>(),
78
79
  stub<IMailboxRepository>(),
80
+ noFolderRoles,
79
81
  messageService,
80
82
  envelopeService,
81
83
  addressService,
@@ -98,6 +100,7 @@ const harvest = async (envelope: Partial<ImapEnvelope>): Promise<Saved> => {
98
100
  accountId: string,
99
101
  accountConfigId: string,
100
102
  msg: ImapMessage,
103
+ roles: AccountFolderRoles,
101
104
  ) => Promise<unknown>;
102
105
  }
103
106
  ).saveMessage(
@@ -105,6 +108,7 @@ const harvest = async (envelope: Partial<ImapEnvelope>): Promise<Saved> => {
105
108
  "acct-1",
106
109
  "cfg-1",
107
110
  msg,
111
+ NO_FOLDER_ROLES,
108
112
  );
109
113
 
110
114
  return saved;
@@ -31,7 +31,8 @@ import type {
31
31
  } from "@remit/data-ports";
32
32
  import { MessageCategory } from "@remit/domain-enums";
33
33
  import type { ManagedConnectionFactory } from "./connection-factory.js";
34
- import { MessageSyncService } from "./message-sync.js";
34
+ import { type AccountFolderRoles, MessageSyncService } from "./message-sync.js";
35
+ import { NO_FOLDER_ROLES, noFolderRoles } from "./test-helpers/folder-roles.js";
35
36
  import type { ImapMessage } from "./types.js";
36
37
 
37
38
  const envelope = {
@@ -97,6 +98,7 @@ const saveIntoMailbox = async (
97
98
  const service = new MessageSyncService(
98
99
  stub<ManagedConnectionFactory>(),
99
100
  stub<IMailboxRepository>(),
101
+ noFolderRoles,
100
102
  messageService,
101
103
  envelopeService,
102
104
  addressService,
@@ -110,6 +112,7 @@ const saveIntoMailbox = async (
110
112
  accountId: string,
111
113
  accountConfigId: string,
112
114
  msg: ImapMessage,
115
+ roles: AccountFolderRoles,
113
116
  ) => Promise<unknown>;
114
117
  }
115
118
  ).saveMessage(
@@ -117,6 +120,7 @@ const saveIntoMailbox = async (
117
120
  "acct-1",
118
121
  "cfg-1",
119
122
  imapMessage,
123
+ NO_FOLDER_ROLES,
120
124
  );
121
125
 
122
126
  assert.equal(inputs.length, 1);
@@ -23,6 +23,7 @@ import type { ManagedConnectionFactory } from "./connection-factory.js";
23
23
  import type { FlagPushService } from "./flag-push.js";
24
24
  import { FlagQueueService } from "./flag-queue.js";
25
25
  import { MessageSyncService } from "./message-sync.js";
26
+ import { noFolderRoles } from "./test-helpers/folder-roles.js";
26
27
  import type { IImapConnection, ImapMessage } from "./types.js";
27
28
 
28
29
  const ACCOUNT_ID = "acc-1";
@@ -267,6 +268,7 @@ const buildHarness = (options: HarnessOptions): Harness => {
267
268
  const service = new MessageSyncService(
268
269
  connectionFactory,
269
270
  mailboxService,
271
+ noFolderRoles,
270
272
  {} as IMessageRepository,
271
273
  {} as IEnvelopeRepository,
272
274
  addressRepository,
@@ -12,6 +12,7 @@ import type {
12
12
  import { MessageCategory, MessageSystemFlag } from "@remit/domain-enums";
13
13
  import type { ManagedConnectionFactory } from "./connection-factory.js";
14
14
  import { MessageSyncService } from "./message-sync.js";
15
+ import { noFolderRoles } from "./test-helpers/folder-roles.js";
15
16
  import type { ImapEnvelope } from "./types.js";
16
17
 
17
18
  // #44: initial sync hardcoded `hasStars: false` on row create, so mail flagged
@@ -83,6 +84,7 @@ const createThreadWithFlags = async (
83
84
  const service = new MessageSyncService(
84
85
  stub<ManagedConnectionFactory>(),
85
86
  stub<IMailboxRepository>(),
87
+ noFolderRoles,
86
88
  stub<IMessageRepository>(),
87
89
  stub<IEnvelopeRepository>(),
88
90
  stub<IAddressRepository>(),
@@ -13,9 +13,13 @@ import type {
13
13
  MessageItem,
14
14
  ThreadMessageItem,
15
15
  } from "@remit/data-ports";
16
- import { MailboxSpecialUse } from "@remit/domain-enums";
17
16
  import type { ManagedConnectionFactory } from "./connection-factory.js";
18
- import { addressSightingIn, MessageSyncService } from "./message-sync.js";
17
+ import {
18
+ type AccountFolderRoles,
19
+ addressSightingIn,
20
+ MessageSyncService,
21
+ } from "./message-sync.js";
22
+ import { folderRoles } from "./test-helpers/folder-roles.js";
19
23
  import type { ImapEnvelope, ImapMessage } from "./types.js";
20
24
 
21
25
  const stub = <T>(): T => ({}) as T;
@@ -41,17 +45,21 @@ interface Saved {
41
45
  reconciled: string[];
42
46
  }
43
47
 
44
- const mailboxAt = (
45
- fullPath: string,
46
- specialUse?: MailboxItem["specialUse"],
47
- ): MailboxItem =>
48
+ const mailboxAt = (fullPath: string): MailboxItem =>
48
49
  ({
50
+ mailboxId: `mbx-${fullPath}`,
49
51
  fullPath,
50
52
  hierarchyDelimiter: "/",
51
- specialUse,
52
53
  }) as MailboxItem;
53
54
 
54
- const save = async (mailbox: MailboxItem): Promise<Saved> => {
55
+ /**
56
+ * Save one message into `mailbox`, with the account holding `role` in that same
57
+ * folder. `undefined` is an account whose Junk and Trash are elsewhere.
58
+ */
59
+ const save = async (
60
+ mailbox: MailboxItem,
61
+ role?: "Junk" | "Trash",
62
+ ): Promise<Saved> => {
55
63
  const saved: Saved = {
56
64
  correspondents: [],
57
65
  junk: [],
@@ -98,6 +106,13 @@ const save = async (mailbox: MailboxItem): Promise<Saved> => {
98
106
  const service = new MessageSyncService(
99
107
  stub<ManagedConnectionFactory>(),
100
108
  stub<IMailboxRepository>(),
109
+ folderRoles(
110
+ role === "Junk"
111
+ ? { junkMailboxId: mailbox.mailboxId }
112
+ : role === "Trash"
113
+ ? { trashMailboxId: mailbox.mailboxId }
114
+ : {},
115
+ ),
101
116
  messageService,
102
117
  envelopeService,
103
118
  addressService,
@@ -120,9 +135,13 @@ const save = async (mailbox: MailboxItem): Promise<Saved> => {
120
135
  accountId: string,
121
136
  accountConfigId: string,
122
137
  msg: ImapMessage,
138
+ roles: AccountFolderRoles,
123
139
  ) => Promise<unknown>;
124
140
  }
125
- ).saveMessage(mailbox, "acct-1", "cfg-1", msg);
141
+ ).saveMessage(mailbox, "acct-1", "cfg-1", msg, {
142
+ junkMailboxId: role === "Junk" ? mailbox.mailboxId : null,
143
+ trashMailboxId: role === "Trash" ? mailbox.mailboxId : null,
144
+ });
126
145
 
127
146
  return saved;
128
147
  };
@@ -133,42 +152,32 @@ const emails = (inputs: Array<{ normalizedEmail: string }>): string[] =>
133
152
  const BOTH = ["sales@pharma.example", "victim@ischen.nl"];
134
153
 
135
154
  describe("what a mailbox says about the addresses on its messages", () => {
136
- const at = (fullPath: string, specialUse?: string[]) => ({
137
- fullPath,
138
- hierarchyDelimiter: "/",
139
- specialUse,
140
- });
141
-
142
- it("reads the special-use designation", () => {
143
- assert.equal(
144
- addressSightingIn(at("INBOX/Spam", [MailboxSpecialUse.Junk])),
145
- "junk",
146
- );
147
- assert.equal(addressSightingIn(at("INBOX")), "correspondent");
155
+ const ROLES = (junk: string | null, trash: string | null) => ({
156
+ junkMailboxId: junk,
157
+ trashMailboxId: trash,
148
158
  });
149
159
 
150
- it("falls back to the folder name on a server without SPECIAL-USE", () => {
151
- assert.equal(addressSightingIn(at("Spam")), "junk");
152
- assert.equal(addressSightingIn(at("[Gmail]/Spam")), "junk");
153
- assert.equal(addressSightingIn(at("Deleted Items")), "discarded");
160
+ it("reads the folder the account appointed, not the folder’s name", () => {
161
+ // The account appointed `INBOX/Rubbish` as Junk. A second folder called
162
+ // `Spam` is an ordinary folder, and its senders are correspondents —
163
+ // a role belongs to one folder (RFC 032), so a name cannot claim it.
164
+ const roles = ROLES("mbx-rubbish", null);
165
+ assert.equal(addressSightingIn("mbx-rubbish", roles), "junk");
166
+ assert.equal(addressSightingIn("mbx-spam", roles), "correspondent");
154
167
  });
155
168
 
156
- it("reads a Junk folder nested under any prefix", () => {
157
- assert.equal(addressSightingIn(at("INBOX/Spam")), "junk");
158
- assert.equal(addressSightingIn(at("INBOX/Junk E-mail")), "junk");
169
+ it("keeps a message in Trash from deciding either way", () => {
159
170
  assert.equal(
160
- addressSightingIn({ fullPath: "Mail.Junk", hierarchyDelimiter: "." }),
161
- "junk",
171
+ addressSightingIn("mbx-trash", ROLES(null, "mbx-trash")),
172
+ "discarded",
162
173
  );
163
174
  });
164
175
 
165
- it("never reads a prefix as the folder it names", () => {
166
- assert.equal(addressSightingIn(at("Spam/Receipts")), "correspondent");
167
- });
168
-
169
- it("answers for a mailbox carrying no delimiter", () => {
170
- assert.equal(addressSightingIn({ fullPath: "Spam" }), "junk");
171
- assert.equal(addressSightingIn({ fullPath: "INBOX" }), "correspondent");
176
+ it("reads an account with neither role as all correspondents", () => {
177
+ assert.equal(
178
+ addressSightingIn("mbx-anything", ROLES(null, null)),
179
+ "correspondent",
180
+ );
172
181
  });
173
182
 
174
183
  it("harvests every envelope address of an ordinary message", async () => {
@@ -180,29 +189,20 @@ describe("what a mailbox says about the addresses on its messages", () => {
180
189
  });
181
190
 
182
191
  it("withholds every envelope address of a message in Junk", async () => {
183
- const saved = await save(mailboxAt("INBOX/Spam", [MailboxSpecialUse.Junk]));
192
+ const saved = await save(mailboxAt("INBOX/Rubbish"), "Junk");
184
193
 
185
194
  assert.deepEqual(emails(saved.junk), BOTH);
186
195
  assert.equal(saved.correspondents.length, 0);
187
196
  });
188
197
 
189
198
  it("still records the envelope a message in Junk renders", async () => {
190
- const saved = await save(mailboxAt("INBOX/Spam", [MailboxSpecialUse.Junk]));
199
+ const saved = await save(mailboxAt("INBOX/Rubbish"), "Junk");
191
200
 
192
201
  assert.deepEqual(emails(saved.envelopeAddresses), BOTH);
193
202
  });
194
203
 
195
- it("withholds when Junk is one designation among several", async () => {
196
- const saved = await save(
197
- mailboxAt("Archive", [MailboxSpecialUse.Junk, MailboxSpecialUse.Archive]),
198
- );
199
-
200
- assert.equal(saved.correspondents.length, 0);
201
- assert.equal(saved.junk.length, 2);
202
- });
203
-
204
204
  it("keeps a message in Trash from deciding either way", async () => {
205
- const saved = await save(mailboxAt("Trash", [MailboxSpecialUse.Trash]));
205
+ const saved = await save(mailboxAt("Trash"), "Trash");
206
206
 
207
207
  assert.deepEqual(emails(saved.neutral), BOTH);
208
208
  assert.equal(saved.correspondents.length, 0);
@@ -210,7 +210,7 @@ describe("what a mailbox says about the addresses on its messages", () => {
210
210
  });
211
211
 
212
212
  it("re-asks every sighting of a sender a message in Junk carries", async () => {
213
- const saved = await save(mailboxAt("INBOX/Spam", [MailboxSpecialUse.Junk]));
213
+ const saved = await save(mailboxAt("INBOX/Rubbish"), "Junk");
214
214
 
215
215
  assert.equal(saved.reconciled.length, 1);
216
216
  assert.deepEqual(saved.reconciled, [saved.envelopeAddresses[0].messageId]);
@@ -218,14 +218,14 @@ describe("what a mailbox says about the addresses on its messages", () => {
218
218
 
219
219
  it("asks nothing of a sender met on live mail", async () => {
220
220
  const inbox = await save(mailboxAt("INBOX"));
221
- const trash = await save(mailboxAt("Trash", [MailboxSpecialUse.Trash]));
221
+ const trash = await save(mailboxAt("Trash"), "Trash");
222
222
 
223
223
  assert.deepEqual(inbox.reconciled, []);
224
224
  assert.deepEqual(trash.reconciled, []);
225
225
  });
226
226
 
227
227
  it("harvests the same message once an ordinary folder holds it", async () => {
228
- await save(mailboxAt("INBOX/Spam", [MailboxSpecialUse.Junk]));
228
+ await save(mailboxAt("INBOX/Rubbish"), "Junk");
229
229
  const moved = await save(mailboxAt("INBOX"));
230
230
 
231
231
  assert.deepEqual(emails(moved.correspondents), BOTH);
@@ -30,6 +30,7 @@ import { MailboxCursorState } from "@remit/domain-enums";
30
30
  import type { ManagedConnectionFactory } from "./connection-factory.js";
31
31
  import { MessageSyncService, selectUidsToSync } from "./message-sync.js";
32
32
  import { QuarantineService } from "./quarantine.js";
33
+ import { noFolderRoles } from "./test-helpers/folder-roles.js";
33
34
  import type { IImapConnection, ImapMessage } from "./types.js";
34
35
 
35
36
  const ACCOUNT_ID = "acc-1";
@@ -179,6 +180,7 @@ const buildHarness = (options: {
179
180
  close: async () => {},
180
181
  } as ManagedConnectionFactory,
181
182
  mailboxService,
183
+ noFolderRoles,
182
184
  {} as IMessageRepository,
183
185
  {} as IEnvelopeRepository,
184
186
  {} as IAddressRepository,
@@ -3,6 +3,7 @@ import type {
3
3
  IAddressRepository,
4
4
  IEnvelopeRepository,
5
5
  IMailboxRepository,
6
+ IMailboxSpecialUseRepository,
6
7
  IMessageFlagPushRepository,
7
8
  IMessageFlagRepository,
8
9
  IMessageRepository,
@@ -21,11 +22,6 @@ import {
21
22
  deriveThreadId,
22
23
  isValidMessageId,
23
24
  } from "@remit/data-ports/id";
24
- import {
25
- isJunkMailbox,
26
- isTrashMailbox,
27
- type MailboxRole,
28
- } from "@remit/data-ports/mailbox-role";
29
25
  import {
30
26
  AddressRole,
31
27
  MailboxCursorState,
@@ -93,9 +89,34 @@ export const isParseableEmailAddress = (
93
89
 
94
90
  export type AddressSighting = "junk" | "discarded" | "correspondent";
95
91
 
96
- export const addressSightingIn = (mailbox: MailboxRole): AddressSighting => {
97
- if (isJunkMailbox(mailbox)) return "junk";
98
- if (isTrashMailbox(mailbox)) return "discarded";
92
+ /** The account's Junk and Trash mailboxes, resolved once per batch. */
93
+ export interface AccountFolderRoles {
94
+ readonly junkMailboxId: string | null;
95
+ readonly trashMailboxId: string | null;
96
+ }
97
+
98
+ /**
99
+ * What a sighting of an address in this mailbox is worth. Junk withholds the
100
+ * sender from autocomplete (#822), Trash decides nothing either way, and
101
+ * anywhere else is a correspondent.
102
+ *
103
+ * Decided by mailbox IDENTITY against the account's resolved roles, not by the
104
+ * folder's flag and name: a role belongs to exactly one folder (RFC 032), and
105
+ * the folder it belongs to is whichever one the user appointed. Reading the
106
+ * name here meant a second folder called `Spam` also withheld its senders,
107
+ * while the folder the user actually appointed harvested every spammer in it as
108
+ * a correspondent (#837).
109
+ */
110
+ export const addressSightingIn = (
111
+ mailboxId: string,
112
+ roles: AccountFolderRoles,
113
+ ): AddressSighting => {
114
+ if (roles.junkMailboxId !== null && mailboxId === roles.junkMailboxId) {
115
+ return "junk";
116
+ }
117
+ if (roles.trashMailboxId !== null && mailboxId === roles.trashMailboxId) {
118
+ return "discarded";
119
+ }
99
120
  return "correspondent";
100
121
  };
101
122
 
@@ -238,6 +259,13 @@ export class MessageSyncService {
238
259
  constructor(
239
260
  private connectionFactory: ManagedConnectionFactory,
240
261
  private mailboxService: IMailboxRepository,
262
+ /**
263
+ * The account’s folder-role map. Required, not optional: which folder
264
+ * holds the Junk role decides whether a sighting withholds its sender
265
+ * from autocomplete, and a construction without it would quietly harvest
266
+ * spammers as correspondents.
267
+ */
268
+ private mailboxSpecialUseService: IMailboxSpecialUseRepository,
241
269
  messageService: IMessageRepository,
242
270
  envelopeService: IEnvelopeRepository,
243
271
  addressService: IAddressRepository,
@@ -435,9 +463,11 @@ export class MessageSyncService {
435
463
  // which catches its own error and reports a `failed` outcome instead of
436
464
  // rejecting. So one bad message can no longer abort the whole batch (the
437
465
  // poison pill that previously froze the mailbox, #817).
466
+ const roles = await this.folderRolesFor(accountId);
438
467
  const outcomes = await pMap(
439
468
  applicable,
440
- (msg) => this.trySaveMessage(mailbox, accountId, accountConfigId, msg),
469
+ (msg) =>
470
+ this.trySaveMessage(mailbox, accountId, accountConfigId, msg, roles),
441
471
  { concurrency: MESSAGE_SAVE_CONCURRENCY },
442
472
  );
443
473
 
@@ -668,9 +698,11 @@ export class MessageSyncService {
668
698
  const newMessages =
669
699
  newUids.length > 0 ? await this.fetchMessageBatch(newUids) : [];
670
700
  const applicable = newMessages.filter((msg) => msg.envelope !== undefined);
701
+ const roles = await this.folderRolesFor(accountId);
671
702
  const outcomes = await pMap(
672
703
  applicable,
673
- (msg) => this.trySaveMessage(mailbox, accountId, accountConfigId, msg),
704
+ (msg) =>
705
+ this.trySaveMessage(mailbox, accountId, accountConfigId, msg, roles),
674
706
  { concurrency: MESSAGE_SAVE_CONCURRENCY },
675
707
  );
676
708
  const syncedMessages: SyncedMessage[] = outcomes.flatMap((o) =>
@@ -894,9 +926,11 @@ export class MessageSyncService {
894
926
  );
895
927
  }
896
928
 
929
+ const roles = await this.folderRolesFor(accountId);
897
930
  const outcomes = await pMap(
898
931
  applicable,
899
- (msg) => this.tryApplyChange(mailbox, accountId, accountConfigId, msg),
932
+ (msg) =>
933
+ this.tryApplyChange(mailbox, accountId, accountConfigId, msg, roles),
900
934
  { concurrency: MESSAGE_SAVE_CONCURRENCY },
901
935
  );
902
936
 
@@ -1005,9 +1039,10 @@ export class MessageSyncService {
1005
1039
  accountId: string,
1006
1040
  accountConfigId: string,
1007
1041
  msg: ImapMessage,
1042
+ roles: AccountFolderRoles,
1008
1043
  ): Promise<BatchOutcome> {
1009
1044
  const mailboxId = mailbox.mailboxId;
1010
- return this.applyChange(mailbox, accountId, accountConfigId, msg)
1045
+ return this.applyChange(mailbox, accountId, accountConfigId, msg, roles)
1011
1046
  .then((result): BatchOutcome => ({ kind: "saved", uid: msg.uid, result }))
1012
1047
  .catch((error): BatchOutcome => {
1013
1048
  this.log.warn(
@@ -1028,6 +1063,7 @@ export class MessageSyncService {
1028
1063
  accountId: string,
1029
1064
  accountConfigId: string,
1030
1065
  msg: ImapMessage,
1066
+ roles: AccountFolderRoles,
1031
1067
  ): Promise<SaveMessageResult | null> {
1032
1068
  if (!msg.envelope) return null;
1033
1069
 
@@ -1046,7 +1082,7 @@ export class MessageSyncService {
1046
1082
  messageId,
1047
1083
  );
1048
1084
  if (!existing) {
1049
- return this.saveMessage(mailbox, accountId, accountConfigId, msg);
1085
+ return this.saveMessage(mailbox, accountId, accountConfigId, msg, roles);
1050
1086
  }
1051
1087
 
1052
1088
  await this.applyServerFlags(existing, msg.flags);
@@ -1240,14 +1276,31 @@ export class MessageSyncService {
1240
1276
  * cycle. This is the guardrail that stops a single unsaveable message from
1241
1277
  * permanently freezing the mailbox (#817).
1242
1278
  */
1279
+ /**
1280
+ * Which folders hold Junk and Trash for this account, resolved once per
1281
+ * batch rather than per message: the lookup reads an appointment row and
1282
+ * the account’s mailbox list, and every message in a batch shares both.
1283
+ */
1284
+ private async folderRolesFor(accountId: string): Promise<AccountFolderRoles> {
1285
+ const [junk, trash] = await Promise.all([
1286
+ this.mailboxSpecialUseService.findJunkMailbox(accountId),
1287
+ this.mailboxSpecialUseService.findTrashMailbox(accountId),
1288
+ ]);
1289
+ return {
1290
+ junkMailboxId: junk?.mailboxId ?? null,
1291
+ trashMailboxId: trash?.mailboxId ?? null,
1292
+ };
1293
+ }
1294
+
1243
1295
  private async trySaveMessage(
1244
1296
  mailbox: MailboxItem,
1245
1297
  accountId: string,
1246
1298
  accountConfigId: string,
1247
1299
  msg: ImapMessage,
1300
+ roles: AccountFolderRoles,
1248
1301
  ): Promise<BatchOutcome> {
1249
1302
  const mailboxId = mailbox.mailboxId;
1250
- return this.saveMessage(mailbox, accountId, accountConfigId, msg)
1303
+ return this.saveMessage(mailbox, accountId, accountConfigId, msg, roles)
1251
1304
  .then((result): BatchOutcome => ({ kind: "saved", uid: msg.uid, result }))
1252
1305
  .catch((error): BatchOutcome => {
1253
1306
  this.log.warn(
@@ -1268,11 +1321,12 @@ export class MessageSyncService {
1268
1321
  accountId: string,
1269
1322
  accountConfigId: string,
1270
1323
  msg: ImapMessage,
1324
+ roles: AccountFolderRoles,
1271
1325
  ): Promise<SaveMessageResult | null> {
1272
1326
  if (!msg.envelope) return null;
1273
1327
 
1274
1328
  const mailboxId = mailbox.mailboxId;
1275
- const sighting = addressSightingIn(mailbox);
1329
+ const sighting = addressSightingIn(mailboxId, roles);
1276
1330
 
1277
1331
  // Store envelope to preserve narrowing in closures
1278
1332
  const envelope = msg.envelope;
@@ -0,0 +1,34 @@
1
+ import type { IMailboxSpecialUseRepository } from "@remit/data-ports";
2
+
3
+ /**
4
+ * A folder-role map for an account that has appointed nothing and whose server
5
+ * flags nothing — every mailbox is an ordinary folder. The stand-in for the
6
+ * many suites that construct `MessageSyncService` for a reason unrelated to
7
+ * which folder holds which role.
8
+ */
9
+ export const noFolderRoles = {
10
+ findJunkMailbox: async () => null,
11
+ findTrashMailbox: async () => null,
12
+ } as unknown as IMailboxSpecialUseRepository;
13
+
14
+ /** A folder-role map naming the mailboxes that hold Junk and Trash. */
15
+ export const folderRoles = (roles: {
16
+ junkMailboxId?: string;
17
+ trashMailboxId?: string;
18
+ }): IMailboxSpecialUseRepository =>
19
+ ({
20
+ findJunkMailbox: async () =>
21
+ roles.junkMailboxId
22
+ ? { mailboxId: roles.junkMailboxId, fullPath: roles.junkMailboxId }
23
+ : null,
24
+ findTrashMailbox: async () =>
25
+ roles.trashMailboxId
26
+ ? { mailboxId: roles.trashMailboxId, fullPath: roles.trashMailboxId }
27
+ : null,
28
+ }) as unknown as IMailboxSpecialUseRepository;
29
+
30
+ /** The same account, as the resolved map `saveMessage` is handed. */
31
+ export const NO_FOLDER_ROLES = {
32
+ junkMailboxId: null,
33
+ trashMailboxId: null,
34
+ };