@remit/mailbox-service 0.0.57 → 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.57",
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,
@@ -0,0 +1,157 @@
1
+ /**
2
+ * A message with nobody to send to must be refused where the person who pressed
3
+ * Send can see the refusal.
4
+ *
5
+ * `@minItems(1)` on `CreateOutboxMessageInput.toAddresses` was the only check
6
+ * anywhere on the path, and neither route into the queue passes it: `send`
7
+ * takes a stored draft, which the update endpoint will happily strip every
8
+ * address off, and `createAndSend` reaches nodemailer with whatever it was
9
+ * handed. Nodemailer refuses an empty envelope inside the SMTP worker, so the
10
+ * message dies in the DLQ and the composer reports a send that went nowhere.
11
+ */
12
+
13
+ import assert from "node:assert/strict";
14
+ import { describe, it } from "node:test";
15
+ import type {
16
+ IAccountRepository,
17
+ IOutboxMessageRepository,
18
+ OutboxMessageItem,
19
+ } from "@remit/data-ports";
20
+ import { BadRequestError } from "@remit/data-ports/errors";
21
+ import { OutboxMessageStatus } from "@remit/domain-enums";
22
+ import type { OutboxAttachmentService } from "./outbox-attachment.js";
23
+ import { OutboxQueueService } from "./outbox-queue.js";
24
+
25
+ const ACCOUNT_CONFIG_ID = "cfg-1";
26
+ const ACCOUNT_ID = "acc-1";
27
+ const OUTBOX_MESSAGE_ID = "ob-1";
28
+
29
+ const draft = (overrides: Partial<OutboxMessageItem>): OutboxMessageItem =>
30
+ ({
31
+ outboxMessageId: OUTBOX_MESSAGE_ID,
32
+ accountId: ACCOUNT_ID,
33
+ accountConfigId: ACCOUNT_CONFIG_ID,
34
+ fromAddress: "me@example.com",
35
+ toAddresses: ["them@example.com"],
36
+ ccAddresses: [],
37
+ bccAddresses: [],
38
+ references: [],
39
+ messageIdValue: "<m1@example.com>",
40
+ status: OutboxMessageStatus.draft,
41
+ createdAt: 0,
42
+ updatedAt: 0,
43
+ ...overrides,
44
+ }) as OutboxMessageItem;
45
+
46
+ interface Harness {
47
+ service: OutboxQueueService;
48
+ enqueued: string[];
49
+ created: number;
50
+ statusWrites: string[];
51
+ }
52
+
53
+ const createHarness = (stored: OutboxMessageItem): Harness => {
54
+ const harness: Harness = {
55
+ service: undefined as unknown as OutboxQueueService,
56
+ enqueued: [],
57
+ created: 0,
58
+ statusWrites: [],
59
+ };
60
+
61
+ const outboxMessageService = {
62
+ get: async () => stored,
63
+ create: async (input: Record<string, unknown>) => {
64
+ harness.created += 1;
65
+ return draft(input as Partial<OutboxMessageItem>);
66
+ },
67
+ updateStatus: async (
68
+ _configId: string,
69
+ _id: string,
70
+ status: OutboxMessageItem["status"],
71
+ ) => {
72
+ harness.statusWrites.push(status);
73
+ return draft({ status });
74
+ },
75
+ } as unknown as IOutboxMessageRepository;
76
+
77
+ harness.service = new OutboxQueueService({
78
+ outboxMessageService,
79
+ outboxAttachmentService: {} as unknown as OutboxAttachmentService,
80
+ accountService: {} as unknown as IAccountRepository,
81
+ sqsSmtpQueueUrl: "http://localhost/queue",
82
+ sqsClient: {
83
+ send: async (command: { input: { MessageBody: string } }) => {
84
+ harness.enqueued.push(command.input.MessageBody);
85
+ return {};
86
+ },
87
+ } as never,
88
+ });
89
+
90
+ return harness;
91
+ };
92
+
93
+ const sendInput = (overrides: Record<string, unknown>) => ({
94
+ accountId: ACCOUNT_ID,
95
+ accountConfigId: ACCOUNT_CONFIG_ID,
96
+ fromAddress: "me@example.com",
97
+ toAddresses: [] as string[],
98
+ ...overrides,
99
+ });
100
+
101
+ describe("OutboxQueueService and a message with nowhere to go", () => {
102
+ it("refuses to queue a stored draft that has lost every address", async () => {
103
+ const harness = createHarness(
104
+ draft({ toAddresses: [], ccAddresses: [], bccAddresses: [] }),
105
+ );
106
+
107
+ await assert.rejects(
108
+ () => harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
109
+ (error: unknown) => {
110
+ assert.ok(error instanceof BadRequestError);
111
+ assert.equal(error.statusCode, 400);
112
+ return true;
113
+ },
114
+ );
115
+
116
+ assert.deepEqual(harness.statusWrites, [], "it stayed a draft");
117
+ assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
118
+ });
119
+
120
+ it("queues a draft addressed only in Bcc — that envelope is real", async () => {
121
+ const harness = createHarness(
122
+ draft({ toAddresses: [], bccAddresses: ["them@example.com"] }),
123
+ );
124
+
125
+ await harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID);
126
+
127
+ assert.deepEqual(harness.statusWrites, [OutboxMessageStatus.queued]);
128
+ assert.equal(harness.enqueued.length, 1);
129
+ });
130
+
131
+ it("refuses a send-immediately create with no addresses, before writing a row", async () => {
132
+ const harness = createHarness(draft({}));
133
+
134
+ await assert.rejects(
135
+ () => harness.service.createAndSend(sendInput({})),
136
+ (error: unknown) => {
137
+ assert.ok(error instanceof BadRequestError);
138
+ assert.equal(error.statusCode, 400);
139
+ return true;
140
+ },
141
+ );
142
+
143
+ assert.equal(harness.created, 0, "no queued row was left behind");
144
+ assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
145
+ });
146
+
147
+ it("still creates and queues a send-immediately message that has a recipient", async () => {
148
+ const harness = createHarness(draft({}));
149
+
150
+ await harness.service.createAndSend(
151
+ sendInput({ toAddresses: ["them@example.com"] }),
152
+ );
153
+
154
+ assert.equal(harness.created, 1);
155
+ assert.equal(harness.enqueued.length, 1);
156
+ });
157
+ });
@@ -5,7 +5,7 @@ import type {
5
5
  IOutboxMessageRepository,
6
6
  OutboxMessageItem,
7
7
  } from "@remit/data-ports";
8
- import { ConflictError } from "@remit/data-ports/errors";
8
+ import { BadRequestError, ConflictError } from "@remit/data-ports/errors";
9
9
  import { OutboxMessageStatus } from "@remit/domain-enums";
10
10
  import { createQueueProducer } from "@remit/sqs-client/producer";
11
11
  import type { OutboxAttachmentService } from "./outbox-attachment.js";
@@ -64,6 +64,29 @@ export interface UpdateDraftInput {
64
64
  references?: string[];
65
65
  }
66
66
 
67
+ /**
68
+ * Nobody to send to. `@minItems(1)` on the create input is the only thing that
69
+ * has ever stood between a zero-recipient message and nodemailer, which refuses
70
+ * an empty envelope — and refuses it inside the SMTP worker, where the failure
71
+ * lands in the DLQ rather than in front of the person who pressed Send. Neither
72
+ * route into the queue passes that schema: a draft is created with a recipient
73
+ * and can be edited down to none, and `send` never sees the create input at all.
74
+ *
75
+ * Cc and Bcc count. A message addressed only in Bcc is a real message with a
76
+ * real envelope; only a message with no address anywhere has nowhere to go.
77
+ */
78
+ const hasNowhereToGo = (message: {
79
+ toAddresses?: string[];
80
+ ccAddresses?: string[];
81
+ bccAddresses?: string[];
82
+ }): boolean =>
83
+ (message.toAddresses?.length ?? 0) === 0 &&
84
+ (message.ccAddresses?.length ?? 0) === 0 &&
85
+ (message.bccAddresses?.length ?? 0) === 0;
86
+
87
+ const NO_RECIPIENT_MESSAGE =
88
+ "This message has nobody to send to. Add a recipient before sending it.";
89
+
67
90
  const generateMessageId = (domain: string): string => {
68
91
  const timestamp = Date.now();
69
92
  const random = randomUUID().replace(/-/g, "").slice(0, 16);
@@ -198,6 +221,10 @@ export class OutboxQueueService {
198
221
  );
199
222
  }
200
223
 
224
+ if (hasNowhereToGo(existing)) {
225
+ throw new BadRequestError(NO_RECIPIENT_MESSAGE);
226
+ }
227
+
201
228
  const updated = await this.outboxMessageService.updateStatus(
202
229
  accountConfigId,
203
230
  outboxMessageId,
@@ -217,6 +244,10 @@ export class OutboxQueueService {
217
244
  createAndSend = async (
218
245
  input: CreateDraftInput,
219
246
  ): Promise<OutboxMessageItem> => {
247
+ if (hasNowhereToGo(input)) {
248
+ throw new BadRequestError(NO_RECIPIENT_MESSAGE);
249
+ }
250
+
220
251
  const domain = extractDomain(input.fromAddress);
221
252
  const messageIdValue = generateMessageId(domain);
222
253
 
@@ -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
  (