@remit/mailbox-service 0.0.58 → 0.0.59

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.58",
3
+ "version": "0.0.59",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -0,0 +1,108 @@
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 { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
11
+
12
+ const ACCOUNT = "acc-1";
13
+ const ACCOUNT_CONFIG = "cfg-1";
14
+ const INBOX = "mbx-inbox";
15
+ const TRASH = "mbx-trash";
16
+ const MESSAGE_ID = "msg-1";
17
+
18
+ const buildWorld = (trashExists: boolean) => {
19
+ const reconciled: string[] = [];
20
+ const message = {
21
+ messageId: MESSAGE_ID,
22
+ mailboxId: INBOX,
23
+ uid: 337,
24
+ status: "active",
25
+ syncStatus: "synced",
26
+ };
27
+
28
+ const messageService = {
29
+ get: async () => [message],
30
+ update: async (_id: string, patch: Record<string, unknown>) =>
31
+ Object.assign(message, patch),
32
+ updateForMove: async (_id: string, patch: Record<string, unknown>) =>
33
+ Object.assign(message, patch),
34
+ } as unknown as IMessageRepository;
35
+
36
+ const threadMessageService = {
37
+ findAllByMessageId: async () => [],
38
+ getByMessageId: async () => ({
39
+ accountConfigId: ACCOUNT_CONFIG,
40
+ threadMessageId: "tm-1",
41
+ messageId: MESSAGE_ID,
42
+ mailboxId: INBOX,
43
+ }),
44
+ update: async () => {},
45
+ delete: async () => {},
46
+ } as unknown as IThreadMessageRepository;
47
+
48
+ const mailboxService = {
49
+ get: async () => [
50
+ { mailboxId: INBOX, fullPath: "INBOX", accountId: ACCOUNT },
51
+ ],
52
+ } as unknown as IMailboxRepository;
53
+
54
+ const mailboxSpecialUseService = {
55
+ findTrashMailbox: async () =>
56
+ trashExists ? { mailboxId: TRASH, fullPath: "Trash" } : null,
57
+ } as unknown as IMailboxSpecialUseRepository;
58
+
59
+ const addressService = {
60
+ reconcileJunkOnlyForMessage: async (messageId: string) => {
61
+ reconciled.push(`${messageId}@${message.mailboxId}`);
62
+ },
63
+ } as unknown as IAddressRepository;
64
+
65
+ const config: MessageMoveConfig = {
66
+ messageService,
67
+ mailboxService,
68
+ mailboxSpecialUseService,
69
+ threadMessageService,
70
+ addressService,
71
+ sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
72
+ };
73
+
74
+ const service = new MessageMoveService(config);
75
+ (
76
+ service as unknown as { enqueueEventsBatch: () => Promise<void> }
77
+ ).enqueueEventsBatch = async () => {};
78
+
79
+ return { service, reconciled };
80
+ };
81
+
82
+ describe("deleting a message re-asks what its senders stand on", () => {
83
+ it("re-asks when the delete moves the message to Trash", async () => {
84
+ const { service, reconciled } = buildWorld(true);
85
+
86
+ await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
87
+
88
+ assert.deepEqual(reconciled, [`${MESSAGE_ID}@${TRASH}`]);
89
+ });
90
+
91
+ it("asks nothing when the delete is permanent", async () => {
92
+ const { service, reconciled } = buildWorld(true);
93
+
94
+ await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT, {
95
+ permanent: true,
96
+ });
97
+
98
+ assert.deepEqual(reconciled, []);
99
+ });
100
+
101
+ it("asks nothing when the account has no Trash folder", async () => {
102
+ const { service, reconciled } = buildWorld(false);
103
+
104
+ await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
105
+
106
+ assert.deepEqual(reconciled, []);
107
+ });
108
+ });
@@ -17,6 +17,11 @@ import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
17
17
  import { MessageSyncService } from "./message-sync.js";
18
18
  import type { IImapConnection, ImapMessage } from "./types.js";
19
19
 
20
+ const stubAddressService = (): IAddressRepository =>
21
+ ({
22
+ reconcileJunkOnlyForMessage: async () => {},
23
+ }) as unknown as IAddressRepository;
24
+
20
25
  // A copy is a per-folder placement of the same mail. These tests pin the three
21
26
  // properties issue #75 turns on: the copy row is deterministic (idempotent), a
22
27
  // delete removes exactly the placement it targets, and neither the copy nor the
@@ -176,6 +181,7 @@ const buildWorld = () => {
176
181
  mailboxService,
177
182
  mailboxSpecialUseService,
178
183
  threadMessageService,
184
+ addressService: stubAddressService(),
179
185
  sqsQueueUrl: "http://localhost:9324/000000000000/message-mgmt",
180
186
  };
181
187
 
@@ -288,7 +294,7 @@ const buildSyncOverDestination = (
288
294
  upsertBodyParts: async () => undefined,
289
295
  } as unknown as IEnvelopeRepository,
290
296
  address: {
291
- upsertAddress: async () => undefined,
297
+ upsertCorrespondentAddress: async () => undefined,
292
298
  upsertEnvelopeAddress: async () => undefined,
293
299
  } as unknown as IAddressRepository,
294
300
  threadMessage: world.threadMessageService,
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import type {
4
+ IAddressRepository,
4
5
  IMailboxRepository,
5
6
  IMailboxSpecialUseRepository,
6
7
  IMessageRepository,
@@ -8,6 +9,11 @@ import type {
8
9
  } from "@remit/data-ports";
9
10
  import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
10
11
 
12
+ const stubAddressService = (): IAddressRepository =>
13
+ ({
14
+ reconcileJunkOnlyForMessage: async () => {},
15
+ }) as unknown as IAddressRepository;
16
+
11
17
  const ACCOUNT = "acc-1";
12
18
  const ACCOUNT_CONFIG = "cfg-1";
13
19
  const INBOX = "mbx-inbox";
@@ -67,6 +73,7 @@ const buildWorld = () => {
67
73
  mailboxService,
68
74
  mailboxSpecialUseService,
69
75
  threadMessageService,
76
+ addressService: stubAddressService(),
70
77
  sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
71
78
  };
72
79
 
@@ -5,6 +5,7 @@ import {
5
5
  type SQSClient,
6
6
  } from "@aws-sdk/client-sqs";
7
7
  import type {
8
+ IAddressRepository,
8
9
  IMailboxRepository,
9
10
  IMailboxSpecialUseRepository,
10
11
  IMessageRepository,
@@ -96,6 +97,7 @@ export interface MessageMoveConfig {
96
97
  mailboxService: IMailboxRepository;
97
98
  mailboxSpecialUseService: IMailboxSpecialUseRepository;
98
99
  threadMessageService: IThreadMessageRepository;
100
+ addressService: IAddressRepository;
99
101
  sqsQueueUrl: string;
100
102
  sqsEndpoint?: string;
101
103
  logger?: MessageMoveLogger;
@@ -125,6 +127,7 @@ export class MessageMoveService {
125
127
  private mailboxService: IMailboxRepository;
126
128
  private mailboxSpecialUseService: IMailboxSpecialUseRepository;
127
129
  private threadMessageService: IThreadMessageRepository;
130
+ private addressService: IAddressRepository;
128
131
  private sqs: SQSClient;
129
132
  private queueUrl: string;
130
133
  private log: MessageMoveLogger;
@@ -134,6 +137,7 @@ export class MessageMoveService {
134
137
  this.mailboxService = config.mailboxService;
135
138
  this.mailboxSpecialUseService = config.mailboxSpecialUseService;
136
139
  this.threadMessageService = config.threadMessageService;
140
+ this.addressService = config.addressService;
137
141
  this.queueUrl = config.sqsQueueUrl;
138
142
  this.log = config.logger ?? noopLogger;
139
143
 
@@ -254,6 +258,8 @@ export class MessageMoveService {
254
258
  true,
255
259
  );
256
260
 
261
+ await this.addressService.reconcileJunkOnlyForMessage(messageId);
262
+
257
263
  events.push({
258
264
  type: "MESSAGE_DELETE",
259
265
  eventId: randomUUID(),
@@ -385,6 +391,8 @@ export class MessageMoveService {
385
391
  isMovingToTrash,
386
392
  );
387
393
 
394
+ await this.addressService.reconcileJunkOnlyForMessage(messageId);
395
+
388
396
  // If moving FROM Trash, clear isDeleted
389
397
  if (isMovingFromTrash) {
390
398
  await this.updateThreadMessageDeleted(accountConfigId, messageId, false);
@@ -9,6 +9,7 @@ import type {
9
9
  IMailboxRepository,
10
10
  IMessageRepository,
11
11
  IThreadMessageRepository,
12
+ MailboxItem,
12
13
  MessageItem,
13
14
  ThreadMessageItem,
14
15
  } from "@remit/data-ports";
@@ -64,7 +65,7 @@ const harvest = async (envelope: Partial<ImapEnvelope>): Promise<Saved> => {
64
65
  } as unknown as IEnvelopeRepository;
65
66
 
66
67
  const addressService = {
67
- upsertAddress: async (input: CreateAddressInput) => {
68
+ upsertCorrespondentAddress: async (input: CreateAddressInput) => {
68
69
  saved.addresses.push(input);
69
70
  },
70
71
  upsertEnvelopeAddress: async (input: CreateEnvelopeAddressInput) => {
@@ -93,13 +94,18 @@ const harvest = async (envelope: Partial<ImapEnvelope>): Promise<Saved> => {
93
94
  await (
94
95
  service as unknown as {
95
96
  saveMessage: (
96
- mailboxId: string,
97
+ mailbox: MailboxItem,
97
98
  accountId: string,
98
99
  accountConfigId: string,
99
100
  msg: ImapMessage,
100
101
  ) => Promise<unknown>;
101
102
  }
102
- ).saveMessage("mbx-1", "acct-1", "cfg-1", msg);
103
+ ).saveMessage(
104
+ { mailboxId: "mbx-1", fullPath: "INBOX" } as MailboxItem,
105
+ "acct-1",
106
+ "cfg-1",
107
+ msg,
108
+ );
103
109
 
104
110
  return saved;
105
111
  };
@@ -25,6 +25,7 @@ import type {
25
25
  IMailboxRepository,
26
26
  IMessageRepository,
27
27
  IThreadMessageRepository,
28
+ MailboxItem,
28
29
  MessageItem,
29
30
  ThreadMessageItem,
30
31
  } from "@remit/data-ports";
@@ -89,7 +90,7 @@ const saveIntoMailbox = async (
89
90
  } as unknown as IEnvelopeRepository;
90
91
 
91
92
  const addressService = {
92
- upsertAddress: async () => {},
93
+ upsertCorrespondentAddress: async () => {},
93
94
  upsertEnvelopeAddress: async () => {},
94
95
  } as unknown as IAddressRepository;
95
96
 
@@ -105,13 +106,18 @@ const saveIntoMailbox = async (
105
106
  await (
106
107
  service as unknown as {
107
108
  saveMessage: (
108
- mailboxId: string,
109
+ mailbox: MailboxItem,
109
110
  accountId: string,
110
111
  accountConfigId: string,
111
112
  msg: ImapMessage,
112
113
  ) => Promise<unknown>;
113
114
  }
114
- ).saveMessage("mbx-1", "acct-1", "cfg-1", imapMessage);
115
+ ).saveMessage(
116
+ { mailboxId: "mbx-1", fullPath: "INBOX" } as MailboxItem,
117
+ "acct-1",
118
+ "cfg-1",
119
+ imapMessage,
120
+ );
115
121
 
116
122
  assert.equal(inputs.length, 1);
117
123
  const [input] = inputs;
@@ -219,7 +219,7 @@ const buildHarness = (options: HarnessOptions): Harness => {
219
219
  upsertBodyParts: async () => undefined,
220
220
  } as unknown as IEnvelopeRepository,
221
221
  address: {
222
- upsertAddress: async () => undefined,
222
+ upsertCorrespondentAddress: async () => undefined,
223
223
  upsertEnvelopeAddress: async () => undefined,
224
224
  } as unknown as IAddressRepository,
225
225
  threadMessage: threadMessageService,
@@ -0,0 +1,233 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type {
4
+ CreateAddressInput,
5
+ CreateEnvelopeAddressInput,
6
+ CreateThreadMessageInput,
7
+ IAddressRepository,
8
+ IEnvelopeRepository,
9
+ IMailboxRepository,
10
+ IMessageRepository,
11
+ IThreadMessageRepository,
12
+ MailboxItem,
13
+ MessageItem,
14
+ ThreadMessageItem,
15
+ } from "@remit/data-ports";
16
+ import { MailboxSpecialUse } from "@remit/domain-enums";
17
+ import type { ManagedConnectionFactory } from "./connection-factory.js";
18
+ import { addressSightingIn, MessageSyncService } from "./message-sync.js";
19
+ import type { ImapEnvelope, ImapMessage } from "./types.js";
20
+
21
+ const stub = <T>(): T => ({}) as T;
22
+
23
+ const envelope: ImapEnvelope = {
24
+ date: new Date(0).toISOString(),
25
+ messageId: "<root@example.com>",
26
+ subject: "Subject",
27
+ from: [{ name: "Pharma Deals", mailbox: "sales", host: "pharma.example" }],
28
+ sender: [],
29
+ replyTo: [],
30
+ to: [{ name: "", mailbox: "victim", host: "ischen.nl" }],
31
+ cc: [],
32
+ bcc: [],
33
+ inReplyTo: "",
34
+ };
35
+
36
+ interface Saved {
37
+ correspondents: CreateAddressInput[];
38
+ junk: CreateAddressInput[];
39
+ neutral: CreateAddressInput[];
40
+ envelopeAddresses: CreateEnvelopeAddressInput[];
41
+ reconciled: string[];
42
+ }
43
+
44
+ const mailboxAt = (
45
+ fullPath: string,
46
+ specialUse?: MailboxItem["specialUse"],
47
+ ): MailboxItem =>
48
+ ({
49
+ fullPath,
50
+ hierarchyDelimiter: "/",
51
+ specialUse,
52
+ }) as MailboxItem;
53
+
54
+ const save = async (mailbox: MailboxItem): Promise<Saved> => {
55
+ const saved: Saved = {
56
+ correspondents: [],
57
+ junk: [],
58
+ neutral: [],
59
+ envelopeAddresses: [],
60
+ reconciled: [],
61
+ };
62
+
63
+ const messageService = {
64
+ upsertWithStatus: async (input: unknown) => ({
65
+ item: input as MessageItem,
66
+ created: true,
67
+ }),
68
+ } as unknown as IMessageRepository;
69
+
70
+ const threadMessageService = {
71
+ create: async (input: CreateThreadMessageInput) =>
72
+ input as unknown as ThreadMessageItem,
73
+ } as unknown as IThreadMessageRepository;
74
+
75
+ const envelopeService = {
76
+ upsertEnvelope: async () => {},
77
+ upsertBodyParts: async () => {},
78
+ } as unknown as IEnvelopeRepository;
79
+
80
+ const addressService = {
81
+ upsertCorrespondentAddress: async (input: CreateAddressInput) => {
82
+ saved.correspondents.push(input);
83
+ },
84
+ upsertJunkAddress: async (input: CreateAddressInput) => {
85
+ saved.junk.push(input);
86
+ },
87
+ upsertAddress: async (input: CreateAddressInput) => {
88
+ saved.neutral.push(input);
89
+ },
90
+ upsertEnvelopeAddress: async (input: CreateEnvelopeAddressInput) => {
91
+ saved.envelopeAddresses.push(input);
92
+ },
93
+ reconcileJunkOnlyForMessage: async (messageId: string) => {
94
+ saved.reconciled.push(messageId);
95
+ },
96
+ } as unknown as IAddressRepository;
97
+
98
+ const service = new MessageSyncService(
99
+ stub<ManagedConnectionFactory>(),
100
+ stub<IMailboxRepository>(),
101
+ messageService,
102
+ envelopeService,
103
+ addressService,
104
+ threadMessageService,
105
+ );
106
+
107
+ const msg = {
108
+ uid: 42,
109
+ seq: 1,
110
+ size: 100,
111
+ internalDate: new Date(0),
112
+ flags: [],
113
+ envelope,
114
+ } as unknown as ImapMessage;
115
+
116
+ await (
117
+ service as unknown as {
118
+ saveMessage: (
119
+ mailbox: MailboxItem,
120
+ accountId: string,
121
+ accountConfigId: string,
122
+ msg: ImapMessage,
123
+ ) => Promise<unknown>;
124
+ }
125
+ ).saveMessage(mailbox, "acct-1", "cfg-1", msg);
126
+
127
+ return saved;
128
+ };
129
+
130
+ const emails = (inputs: Array<{ normalizedEmail: string }>): string[] =>
131
+ inputs.map((input) => input.normalizedEmail);
132
+
133
+ const BOTH = ["sales@pharma.example", "victim@ischen.nl"];
134
+
135
+ 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");
148
+ });
149
+
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");
154
+ });
155
+
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");
159
+ assert.equal(
160
+ addressSightingIn({ fullPath: "Mail.Junk", hierarchyDelimiter: "." }),
161
+ "junk",
162
+ );
163
+ });
164
+
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");
172
+ });
173
+
174
+ it("harvests every envelope address of an ordinary message", async () => {
175
+ const saved = await save(mailboxAt("INBOX"));
176
+
177
+ assert.deepEqual(emails(saved.correspondents), BOTH);
178
+ assert.equal(saved.junk.length, 0);
179
+ assert.equal(saved.neutral.length, 0);
180
+ });
181
+
182
+ it("withholds every envelope address of a message in Junk", async () => {
183
+ const saved = await save(mailboxAt("INBOX/Spam", [MailboxSpecialUse.Junk]));
184
+
185
+ assert.deepEqual(emails(saved.junk), BOTH);
186
+ assert.equal(saved.correspondents.length, 0);
187
+ });
188
+
189
+ it("still records the envelope a message in Junk renders", async () => {
190
+ const saved = await save(mailboxAt("INBOX/Spam", [MailboxSpecialUse.Junk]));
191
+
192
+ assert.deepEqual(emails(saved.envelopeAddresses), BOTH);
193
+ });
194
+
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
+ it("keeps a message in Trash from deciding either way", async () => {
205
+ const saved = await save(mailboxAt("Trash", [MailboxSpecialUse.Trash]));
206
+
207
+ assert.deepEqual(emails(saved.neutral), BOTH);
208
+ assert.equal(saved.correspondents.length, 0);
209
+ assert.equal(saved.junk.length, 0);
210
+ });
211
+
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]));
214
+
215
+ assert.equal(saved.reconciled.length, 1);
216
+ assert.deepEqual(saved.reconciled, [saved.envelopeAddresses[0].messageId]);
217
+ });
218
+
219
+ it("asks nothing of a sender met on live mail", async () => {
220
+ const inbox = await save(mailboxAt("INBOX"));
221
+ const trash = await save(mailboxAt("Trash", [MailboxSpecialUse.Trash]));
222
+
223
+ assert.deepEqual(inbox.reconciled, []);
224
+ assert.deepEqual(trash.reconciled, []);
225
+ });
226
+
227
+ it("harvests the same message once an ordinary folder holds it", async () => {
228
+ await save(mailboxAt("INBOX/Spam", [MailboxSpecialUse.Junk]));
229
+ const moved = await save(mailboxAt("INBOX"));
230
+
231
+ assert.deepEqual(emails(moved.correspondents), BOTH);
232
+ });
233
+ });
@@ -159,7 +159,7 @@ const buildHarness = (options: {
159
159
  upsertBodyParts: async () => undefined,
160
160
  } as unknown as IEnvelopeRepository,
161
161
  address: {
162
- upsertAddress: async () => undefined,
162
+ upsertCorrespondentAddress: async () => undefined,
163
163
  upsertEnvelopeAddress: async () => undefined,
164
164
  } as unknown as IAddressRepository,
165
165
  threadMessage: threadMessageService,
@@ -21,6 +21,11 @@ import {
21
21
  deriveThreadId,
22
22
  isValidMessageId,
23
23
  } from "@remit/data-ports/id";
24
+ import {
25
+ isJunkMailbox,
26
+ isTrashMailbox,
27
+ type MailboxRole,
28
+ } from "@remit/data-ports/mailbox-role";
24
29
  import {
25
30
  AddressRole,
26
31
  MailboxCursorState,
@@ -86,6 +91,14 @@ export const isParseableEmailAddress = (
86
91
  return host.includes(".");
87
92
  };
88
93
 
94
+ export type AddressSighting = "junk" | "discarded" | "correspondent";
95
+
96
+ export const addressSightingIn = (mailbox: MailboxRole): AddressSighting => {
97
+ if (isJunkMailbox(mailbox)) return "junk";
98
+ if (isTrashMailbox(mailbox)) return "discarded";
99
+ return "correspondent";
100
+ };
101
+
89
102
  /**
90
103
  * The name an envelope carries for an address, as it should be stored: any
91
104
  * claim to be some other address removed (issue #826), the rest of the name
@@ -424,7 +437,7 @@ export class MessageSyncService {
424
437
  // poison pill that previously froze the mailbox, #817).
425
438
  const outcomes = await pMap(
426
439
  applicable,
427
- (msg) => this.trySaveMessage(mailboxId, accountId, accountConfigId, msg),
440
+ (msg) => this.trySaveMessage(mailbox, accountId, accountConfigId, msg),
428
441
  { concurrency: MESSAGE_SAVE_CONCURRENCY },
429
442
  );
430
443
 
@@ -657,7 +670,7 @@ export class MessageSyncService {
657
670
  const applicable = newMessages.filter((msg) => msg.envelope !== undefined);
658
671
  const outcomes = await pMap(
659
672
  applicable,
660
- (msg) => this.trySaveMessage(mailboxId, accountId, accountConfigId, msg),
673
+ (msg) => this.trySaveMessage(mailbox, accountId, accountConfigId, msg),
661
674
  { concurrency: MESSAGE_SAVE_CONCURRENCY },
662
675
  );
663
676
  const syncedMessages: SyncedMessage[] = outcomes.flatMap((o) =>
@@ -883,7 +896,7 @@ export class MessageSyncService {
883
896
 
884
897
  const outcomes = await pMap(
885
898
  applicable,
886
- (msg) => this.tryApplyChange(mailboxId, accountId, accountConfigId, msg),
899
+ (msg) => this.tryApplyChange(mailbox, accountId, accountConfigId, msg),
887
900
  { concurrency: MESSAGE_SAVE_CONCURRENCY },
888
901
  );
889
902
 
@@ -988,12 +1001,13 @@ export class MessageSyncService {
988
1001
  * holds the watermark back instead of failing the round.
989
1002
  */
990
1003
  private async tryApplyChange(
991
- mailboxId: string,
1004
+ mailbox: MailboxItem,
992
1005
  accountId: string,
993
1006
  accountConfigId: string,
994
1007
  msg: ImapMessage,
995
1008
  ): Promise<BatchOutcome> {
996
- return this.applyChange(mailboxId, accountId, accountConfigId, msg)
1009
+ const mailboxId = mailbox.mailboxId;
1010
+ return this.applyChange(mailbox, accountId, accountConfigId, msg)
997
1011
  .then((result): BatchOutcome => ({ kind: "saved", uid: msg.uid, result }))
998
1012
  .catch((error): BatchOutcome => {
999
1013
  this.log.warn(
@@ -1010,7 +1024,7 @@ export class MessageSyncService {
1010
1024
  }
1011
1025
 
1012
1026
  private async applyChange(
1013
- mailboxId: string,
1027
+ mailbox: MailboxItem,
1014
1028
  accountId: string,
1015
1029
  accountConfigId: string,
1016
1030
  msg: ImapMessage,
@@ -1020,7 +1034,7 @@ export class MessageSyncService {
1020
1034
  const messageId = deriveMessageIdFromSource(accountId, {
1021
1035
  messageId: msg.envelope.messageId,
1022
1036
  uid: msg.uid,
1023
- mailboxId,
1037
+ mailboxId: mailbox.mailboxId,
1024
1038
  date: msg.envelope.date,
1025
1039
  subject: msg.envelope.subject,
1026
1040
  fromMailbox: msg.envelope.from?.[0]?.mailbox,
@@ -1032,7 +1046,7 @@ export class MessageSyncService {
1032
1046
  messageId,
1033
1047
  );
1034
1048
  if (!existing) {
1035
- return this.saveMessage(mailboxId, accountId, accountConfigId, msg);
1049
+ return this.saveMessage(mailbox, accountId, accountConfigId, msg);
1036
1050
  }
1037
1051
 
1038
1052
  await this.applyServerFlags(existing, msg.flags);
@@ -1227,12 +1241,13 @@ export class MessageSyncService {
1227
1241
  * permanently freezing the mailbox (#817).
1228
1242
  */
1229
1243
  private async trySaveMessage(
1230
- mailboxId: string,
1244
+ mailbox: MailboxItem,
1231
1245
  accountId: string,
1232
1246
  accountConfigId: string,
1233
1247
  msg: ImapMessage,
1234
1248
  ): Promise<BatchOutcome> {
1235
- return this.saveMessage(mailboxId, accountId, accountConfigId, msg)
1249
+ const mailboxId = mailbox.mailboxId;
1250
+ return this.saveMessage(mailbox, accountId, accountConfigId, msg)
1236
1251
  .then((result): BatchOutcome => ({ kind: "saved", uid: msg.uid, result }))
1237
1252
  .catch((error): BatchOutcome => {
1238
1253
  this.log.warn(
@@ -1249,13 +1264,16 @@ export class MessageSyncService {
1249
1264
  }
1250
1265
 
1251
1266
  private async saveMessage(
1252
- mailboxId: string,
1267
+ mailbox: MailboxItem,
1253
1268
  accountId: string,
1254
1269
  accountConfigId: string,
1255
1270
  msg: ImapMessage,
1256
1271
  ): Promise<SaveMessageResult | null> {
1257
1272
  if (!msg.envelope) return null;
1258
1273
 
1274
+ const mailboxId = mailbox.mailboxId;
1275
+ const sighting = addressSightingIn(mailbox);
1276
+
1259
1277
  // Store envelope to preserve narrowing in closures
1260
1278
  const envelope = msg.envelope;
1261
1279
 
@@ -1338,6 +1356,7 @@ export class MessageSyncService {
1338
1356
  accountConfigId,
1339
1357
  addresses,
1340
1358
  role,
1359
+ sighting,
1341
1360
  );
1342
1361
  }
1343
1362
 
@@ -1359,6 +1378,10 @@ export class MessageSyncService {
1359
1378
  });
1360
1379
  owned = created || item.mailboxId === mailboxId;
1361
1380
 
1381
+ if (sighting === "junk") {
1382
+ await repos.address.reconcileJunkOnlyForMessage(messageId);
1383
+ }
1384
+
1362
1385
  await this.createThreadForMessage(
1363
1386
  repos.threadMessage,
1364
1387
  messageId,
@@ -1393,6 +1416,7 @@ export class MessageSyncService {
1393
1416
  accountConfigId: string,
1394
1417
  addresses: ImapAddress[] | undefined,
1395
1418
  role: (typeof AddressRole)[keyof typeof AddressRole],
1419
+ sighting: AddressSighting,
1396
1420
  ) {
1397
1421
  if (!addresses) return;
1398
1422
 
@@ -1430,7 +1454,7 @@ export class MessageSyncService {
1430
1454
 
1431
1455
  const envelopeAddressId = deriveEnvelopeAddressId(messageId, role, order);
1432
1456
 
1433
- await addressService.upsertAddress({
1457
+ const addressInput = {
1434
1458
  addressId,
1435
1459
  accountConfigId,
1436
1460
  localPart,
@@ -1438,7 +1462,14 @@ export class MessageSyncService {
1438
1462
  normalizedEmail,
1439
1463
  normalizedCompound,
1440
1464
  displayName,
1441
- });
1465
+ };
1466
+ if (sighting === "junk") {
1467
+ await addressService.upsertJunkAddress(addressInput);
1468
+ } else if (sighting === "discarded") {
1469
+ await addressService.upsertAddress(addressInput);
1470
+ } else {
1471
+ await addressService.upsertCorrespondentAddress(addressInput);
1472
+ }
1442
1473
 
1443
1474
  await addressService.upsertEnvelopeAddress({
1444
1475
  envelopeAddressId,
@@ -10,6 +10,7 @@ import assert from "node:assert/strict";
10
10
  import { afterEach, beforeEach, describe, it, mock } from "node:test";
11
11
  import { SQSClient } from "@aws-sdk/client-sqs";
12
12
  import type {
13
+ IAddressRepository,
13
14
  IMessagePlacementMoveRepository,
14
15
  IMessageRepository,
15
16
  IThreadMessageRepository,
@@ -33,6 +34,7 @@ interface Harness {
33
34
  row: MessageItem;
34
35
  puts: PutMessagePlacementMoveInput[];
35
36
  marker: MessagePlacementMoveItem | null;
37
+ reconciled: string[];
36
38
  }
37
39
 
38
40
  const buildHarness = (): Harness => {
@@ -46,7 +48,14 @@ const buildHarness = (): Harness => {
46
48
  } as unknown as MessageItem;
47
49
 
48
50
  const puts: PutMessagePlacementMoveInput[] = [];
49
- const harness = { row, puts, marker: null } as Harness;
51
+ const reconciled: string[] = [];
52
+ const harness = { row, puts, marker: null, reconciled } as Harness;
53
+
54
+ const addressService = {
55
+ reconcileJunkOnlyForMessage: async (messageId: string) => {
56
+ reconciled.push(`${messageId}@${row.mailboxId}`);
57
+ },
58
+ } as unknown as IAddressRepository;
50
59
 
51
60
  const messageService = {
52
61
  get: async () => row,
@@ -101,6 +110,7 @@ const buildHarness = (): Harness => {
101
110
  messageService,
102
111
  threadMessageService,
103
112
  markerService,
113
+ addressService,
104
114
  sqsQueueUrl: "https://sqs.eu-west-1.amazonaws.com/000/message-mgmt",
105
115
  moveSettleTimeoutMs: 200,
106
116
  moveSettlePollMs: 10,
@@ -222,4 +232,28 @@ describe("PlacementMoveService — a second destination while the first move is
222
232
  assert.equal(harness.puts[1]?.sourceMailboxId, ARCHIVE_ID);
223
233
  assert.equal(harness.puts[1]?.destinationMailboxId, JUNK_ID);
224
234
  });
235
+
236
+ it("marks the sender when the classifier demotes the message", async () => {
237
+ await harness.service.moveMessage(
238
+ ACCOUNT_CONFIG_ID,
239
+ MESSAGE_ID,
240
+ JUNK_ID,
241
+ ACCOUNT_ID,
242
+ );
243
+
244
+ assert.deepEqual(harness.reconciled, [`${MESSAGE_ID}@${JUNK_ID}`]);
245
+ });
246
+
247
+ it("asks nothing of a destination the message is already in", async () => {
248
+ Object.assign(harness.row, { mailboxId: JUNK_ID });
249
+
250
+ await harness.service.moveMessage(
251
+ ACCOUNT_CONFIG_ID,
252
+ MESSAGE_ID,
253
+ JUNK_ID,
254
+ ACCOUNT_ID,
255
+ );
256
+
257
+ assert.deepEqual(harness.reconciled, []);
258
+ });
225
259
  });
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
3
3
  import type {
4
+ IAddressRepository,
4
5
  IMessagePlacementMoveRepository,
5
6
  IMessageRepository,
6
7
  IThreadMessageRepository,
@@ -42,6 +43,7 @@ export interface PlacementMoveConfig {
42
43
  messageService: IMessageRepository;
43
44
  threadMessageService: IThreadMessageRepository;
44
45
  markerService: IMessagePlacementMoveRepository;
46
+ addressService: IAddressRepository;
45
47
  sqsQueueUrl: string;
46
48
  sqsEndpoint?: string;
47
49
  logger?: PlacementMoveLogger;
@@ -84,6 +86,7 @@ export class PlacementMoveService {
84
86
  private messageService: IMessageRepository;
85
87
  private threadMessageService: IThreadMessageRepository;
86
88
  private markerService: IMessagePlacementMoveRepository;
89
+ private addressService: IAddressRepository;
87
90
  private sqs: SQSClient;
88
91
  private queueUrl: string;
89
92
  private log: PlacementMoveLogger;
@@ -94,6 +97,7 @@ export class PlacementMoveService {
94
97
  this.messageService = config.messageService;
95
98
  this.threadMessageService = config.threadMessageService;
96
99
  this.markerService = config.markerService;
100
+ this.addressService = config.addressService;
97
101
  this.queueUrl = config.sqsQueueUrl;
98
102
  this.log = config.logger ?? noopLogger;
99
103
  this.moveSettleTimeoutMs =
@@ -196,6 +200,8 @@ export class PlacementMoveService {
196
200
  originalUid: message.uid,
197
201
  });
198
202
 
203
+ await this.addressService.reconcileJunkOnlyForMessage(messageId);
204
+
199
205
  // The queue kick is a serious operational step, not a routine one — a
200
206
  // failure here MUST propagate (never swallowed): the marker stays
201
207
  // `pending`, the surrounding body-sync call fails, SQS redelivers, and
@@ -204,6 +204,7 @@ const buildWorld = (
204
204
  addresses.set(input.addressId, { flags: {} });
205
205
  return { ...input, flags: {} };
206
206
  },
207
+ reconcileJunkOnlyForMessage: async () => {},
207
208
  mergeFlags: async (
208
209
  _accountConfigId: string,
209
210
  addressId: string,
@@ -273,6 +274,7 @@ const buildWorld = (
273
274
  mailboxService,
274
275
  mailboxSpecialUseService,
275
276
  threadMessageService,
277
+ addressService,
276
278
  sqsQueueUrl: "http://localhost:9324/000000000000/message-mgmt",
277
279
  });
278
280
  (