@remit/mailbox-service 0.0.56 → 0.0.57

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.56",
3
+ "version": "0.0.57",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -254,6 +254,7 @@ export {
254
254
  } from "./snippet.js";
255
255
  export {
256
256
  MoveNotSettledError,
257
+ NoJunkMailboxError,
257
258
  type SpamReportConfig,
258
259
  type SpamReportLogger,
259
260
  type SpamReportParams,
@@ -10,10 +10,15 @@ import type {
10
10
  IMessageRepository,
11
11
  IThreadMessageRepository,
12
12
  } from "@remit/data-ports";
13
+ import { deriveAddressId } from "@remit/data-ports/id";
13
14
  import { AddressRole } from "@remit/domain-enums";
14
15
  import { FlagPushService } from "./flag-push.js";
15
16
  import { MessageMoveService } from "./message-move.js";
16
- import { MoveNotSettledError, SpamReportService } from "./spam-report.js";
17
+ import {
18
+ MoveNotSettledError,
19
+ NoJunkMailboxError,
20
+ SpamReportService,
21
+ } from "./spam-report.js";
17
22
 
18
23
  const ACCOUNT = "acc-1";
19
24
  const ACCOUNT_CONFIG = "cfg-1";
@@ -21,7 +26,8 @@ const ACCOUNT_EMAIL = "me@example.com";
21
26
  const INBOX_MAILBOX = "mbx-inbox";
22
27
  const JUNK_MAILBOX = "mbx-junk";
23
28
  const MESSAGE_ID = "msg-1";
24
- const ADDRESS_ID = "addr-1";
29
+ const SENDER_EMAIL = "sender@example.com";
30
+ const ADDRESS_ID = deriveAddressId(ACCOUNT_CONFIG, SENDER_EMAIL);
25
31
  const THREAD_ID = "thread-1";
26
32
 
27
33
  interface ThreadRow {
@@ -59,10 +65,21 @@ const buildWorld = (
59
65
  startMailbox?: string;
60
66
  fromEmail?: string;
61
67
  originalMailboxId?: string;
68
+ /** Whether the sender was ever harvested into an `address` row. */
69
+ harvestedSender?: boolean;
70
+ /** Flags already standing on the harvested sender's row. */
71
+ senderFlags?: Record<string, unknown>;
72
+ junkMailbox?: { mailboxId: string; fullPath: string } | null;
62
73
  } = {},
63
74
  ): World => {
64
75
  const startMailbox = opts.startMailbox ?? INBOX_MAILBOX;
65
- const fromEmail = opts.fromEmail ?? "sender@example.com";
76
+ const fromEmail = opts.fromEmail ?? SENDER_EMAIL;
77
+ const fromAddressId = deriveAddressId(ACCOUNT_CONFIG, fromEmail);
78
+ const harvestedSender = opts.harvestedSender ?? true;
79
+ const junkMailbox =
80
+ opts.junkMailbox === undefined
81
+ ? { mailboxId: JUNK_MAILBOX, fullPath: "Junk" }
82
+ : opts.junkMailbox;
66
83
 
67
84
  const messages = new Map<string, Record<string, unknown>>([
68
85
  [
@@ -85,9 +102,11 @@ const buildWorld = (
85
102
  ],
86
103
  ]);
87
104
 
88
- const addresses = new Map<string, { flags: AddressFlags }>([
89
- [ADDRESS_ID, { flags: {} }],
90
- ]);
105
+ const addresses = new Map<string, { flags: AddressFlags }>(
106
+ harvestedSender
107
+ ? [[fromAddressId, { flags: (opts.senderFlags ?? {}) as AddressFlags }]]
108
+ : [],
109
+ );
91
110
 
92
111
  const threadRows: ThreadRow[] = [
93
112
  {
@@ -134,7 +153,8 @@ const buildWorld = (
134
153
  {
135
154
  envelopeAddressId: "ea-1",
136
155
  messageId: id,
137
- addressId: ADDRESS_ID,
156
+ addressId: fromAddressId,
157
+ displayName: "Spammy Sender",
138
158
  normalizedEmail: fromEmail,
139
159
  addressRole: AddressRole.From,
140
160
  addressOrder: 0,
@@ -173,6 +193,17 @@ const buildWorld = (
173
193
  } as unknown as IMessageRepository;
174
194
 
175
195
  const addressService = {
196
+ getAddress: async (_accountConfigId: string, addressIds: string[]) =>
197
+ addressIds
198
+ .filter((id) => addresses.has(id))
199
+ .map((id) => ({ addressId: id, ...addresses.get(id) })),
200
+ // Deliberately destructive on conflict, mirroring the real repo's
201
+ // `onConflictDoUpdate`: a caller that upserts over a row it did not
202
+ // create loses what stood on it.
203
+ upsertAddress: async (input: { addressId: string }) => {
204
+ addresses.set(input.addressId, { flags: {} });
205
+ return { ...input, flags: {} };
206
+ },
176
207
  mergeFlags: async (
177
208
  _accountConfigId: string,
178
209
  addressId: string,
@@ -199,10 +230,7 @@ const buildWorld = (
199
230
  } as unknown as IAccountRepository;
200
231
 
201
232
  const mailboxSpecialUseService = {
202
- findJunkMailbox: async () => ({
203
- mailboxId: JUNK_MAILBOX,
204
- fullPath: "Junk",
205
- }),
233
+ findJunkMailbox: async () => junkMailbox,
206
234
  findTrashMailbox: async () => null,
207
235
  } as unknown as IMailboxSpecialUseRepository;
208
236
 
@@ -399,9 +427,133 @@ describe("SpamReportService.reportSpam", () => {
399
427
  assert.ok(message !== undefined);
400
428
  assert.ok((message.spamReport as { reportedAt: number }).reportedAt > 0);
401
429
  });
430
+
431
+ it("refuses to mint a row for a sender address carrying no @ at all", async () => {
432
+ // slice() around a lastIndexOf of -1 splits "no-at-sign" into a
433
+ // plausible-looking local part and domain, so a length check alone
434
+ // waves the one input it exists to catch straight through into a
435
+ // corrupt address row.
436
+ const { service, addresses } = buildWorld({
437
+ harvestedSender: false,
438
+ fromEmail: "no-at-sign",
439
+ });
440
+
441
+ await assert.rejects(
442
+ () =>
443
+ service.reportSpam({
444
+ accountConfigId: ACCOUNT_CONFIG,
445
+ accountId: ACCOUNT,
446
+ messageId: MESSAGE_ID,
447
+ }),
448
+ /not a usable email address/,
449
+ );
450
+
451
+ assert.equal(addresses.size, 0);
452
+ });
453
+
454
+ it("reports a message whose sender was never harvested into an address row", async () => {
455
+ // The row is what carries the blocked flag, and harvesting is what
456
+ // ordinarily writes it. When it is absent, blocking the sender used to
457
+ // throw NotFoundError and take the whole report down with it — the
458
+ // message never reached Junk and the user got a retry that could not
459
+ // work (test.remit.email, 18 Aug 2026).
460
+ const { service, messages, addresses, sent } = buildWorld({
461
+ harvestedSender: false,
462
+ });
463
+
464
+ await service.reportSpam({
465
+ accountConfigId: ACCOUNT_CONFIG,
466
+ accountId: ACCOUNT,
467
+ messageId: MESSAGE_ID,
468
+ setBy: "user-1",
469
+ });
470
+
471
+ const address = addresses.get(ADDRESS_ID);
472
+ assert.equal(address?.flags.blocked?.value, true);
473
+
474
+ const message = messages.get(MESSAGE_ID);
475
+ assert.equal(message?.mailboxId, JUNK_MAILBOX);
476
+ assert.equal(moveEvents(sent).length, 1);
477
+ });
478
+
479
+ it("keeps the junkOnly mark that withholds the sender from autocomplete", async () => {
480
+ // Report spam adds the block; it is not a sighting of the sender and
481
+ // must not carry an upsert's on-conflict behaviour onto a row it did
482
+ // not create. The mark that withholds a spammer from autocomplete (#822)
483
+ // lives in these same flags, and clearing it here would put the spammer
484
+ // back in the compose picker — the opposite of what the button means.
485
+ const { service, addresses } = buildWorld({
486
+ senderFlags: {
487
+ junkOnly: { value: true, setAt: 1, setBy: "junk-harvest" },
488
+ },
489
+ });
490
+
491
+ await service.reportSpam({
492
+ accountConfigId: ACCOUNT_CONFIG,
493
+ accountId: ACCOUNT,
494
+ messageId: MESSAGE_ID,
495
+ setBy: "user-1",
496
+ });
497
+
498
+ const flags = addresses.get(ADDRESS_ID)?.flags as Record<string, unknown>;
499
+ assert.equal((flags.blocked as { value: boolean }).value, true);
500
+ assert.deepEqual(flags.junkOnly, {
501
+ value: true,
502
+ setAt: 1,
503
+ setBy: "junk-harvest",
504
+ });
505
+ });
506
+
507
+ it("names the missing Junk folder and changes nothing when the account has none", async () => {
508
+ const { service, messages, addresses, sent, markerPuts } = buildWorld({
509
+ junkMailbox: null,
510
+ });
511
+
512
+ await assert.rejects(
513
+ () =>
514
+ service.reportSpam({
515
+ accountConfigId: ACCOUNT_CONFIG,
516
+ accountId: ACCOUNT,
517
+ messageId: MESSAGE_ID,
518
+ }),
519
+ (error: unknown) =>
520
+ error instanceof NoJunkMailboxError &&
521
+ /no Junk folder/.test(error.message) &&
522
+ /Create one/.test(error.message),
523
+ );
524
+
525
+ // Nothing half-applied: no sender blocked, no report stamp on a message
526
+ // still sitting where it was, no move, no keyword marker.
527
+ assert.equal(addresses.get(ADDRESS_ID)?.flags.blocked, undefined);
528
+ const message = messages.get(MESSAGE_ID);
529
+ assert.equal(message?.spamReport, undefined);
530
+ assert.equal(message?.mailboxId, INBOX_MAILBOX);
531
+ assert.equal(moveEvents(sent).length, 0);
532
+ assert.equal(markerPuts.length, 0);
533
+ });
402
534
  });
403
535
 
404
536
  describe("SpamReportService.notSpam", () => {
537
+ it("mints no address row for a sender that was never harvested", async () => {
538
+ // A sender with no row has no block to lift, so the flag write is
539
+ // already true. Creating the row to write it would put an address
540
+ // nothing ever harvested into the address book, where autocomplete
541
+ // would then offer it.
542
+ const { service, messages, addresses } = buildWorld({
543
+ harvestedSender: false,
544
+ startMailbox: JUNK_MAILBOX,
545
+ });
546
+
547
+ await service.notSpam({
548
+ accountConfigId: ACCOUNT_CONFIG,
549
+ accountId: ACCOUNT,
550
+ messageId: MESSAGE_ID,
551
+ });
552
+
553
+ assert.equal(addresses.size, 0);
554
+ assert.equal(messages.get(MESSAGE_ID)?.spamReport, undefined);
555
+ });
556
+
405
557
  it("restores the original mailbox and clears the flag without setting trust", async () => {
406
558
  const { service, messages, addresses } = buildWorld();
407
559
 
@@ -5,6 +5,7 @@ import type {
5
5
  IMessageRepository,
6
6
  MessageItem,
7
7
  } from "@remit/data-ports";
8
+ import { deriveAddressId } from "@remit/data-ports/id";
8
9
  import {
9
10
  AddressRole,
10
11
  MessageKeywordFlag,
@@ -33,6 +34,22 @@ export class MoveNotSettledError extends Error {
33
34
  }
34
35
  }
35
36
 
37
+ /**
38
+ * The account advertises no Junk folder and has none under any conventional
39
+ * name, so there is nowhere to file the report. A designed, user-facing
40
+ * outcome — the user has to create the folder, and no amount of retrying
41
+ * changes that — so it carries its own text through the bulk handler's
42
+ * allowlist rather than being flattened to the generic retry copy.
43
+ */
44
+ export class NoJunkMailboxError extends Error {
45
+ constructor() {
46
+ super(
47
+ "This account has no Junk folder. Create one named Junk or Spam in your mail provider, then report this message again.",
48
+ );
49
+ this.name = "NoJunkMailboxError";
50
+ }
51
+ }
52
+
36
53
  const noopLogger: SpamReportLogger = {
37
54
  info: () => {},
38
55
  error: () => {},
@@ -86,9 +103,20 @@ export class SpamReportService {
86
103
  config.moveSettlePollMs ?? DEFAULT_MOVE_SETTLE_POLL_MS;
87
104
  }
88
105
 
106
+ /**
107
+ * The sender to act on. `addressId` is derived rather than read off the
108
+ * envelope row so it always names the row `ensureSenderAddress` writes and
109
+ * the harvester writes — one derivation, one row, whatever the envelope
110
+ * happens to carry.
111
+ */
89
112
  private resolveFromAddress = async (
113
+ accountConfigId: string,
90
114
  messageId: string,
91
- ): Promise<{ addressId: string; normalizedEmail: string }> => {
115
+ ): Promise<{
116
+ addressId: string;
117
+ normalizedEmail: string;
118
+ displayName: string;
119
+ }> => {
92
120
  const description = await this.messageService.describe(messageId);
93
121
  const from = description.envelopeAddress.find(
94
122
  (a) => a.addressRole === AddressRole.From,
@@ -96,7 +124,65 @@ export class SpamReportService {
96
124
  if (!from) {
97
125
  throw new Error(`Message ${messageId} has no From address to act on`);
98
126
  }
99
- return { addressId: from.addressId, normalizedEmail: from.normalizedEmail };
127
+ return {
128
+ addressId: deriveAddressId(accountConfigId, from.normalizedEmail),
129
+ normalizedEmail: from.normalizedEmail,
130
+ displayName: from.displayName ?? "",
131
+ };
132
+ };
133
+
134
+ /**
135
+ * The list form answers `[]` for a row that is not there, where the single
136
+ * form throws — absence is a case both callers handle, not a fault.
137
+ */
138
+ private senderIsKnown = async (
139
+ accountConfigId: string,
140
+ addressId: string,
141
+ ): Promise<boolean> => {
142
+ const [known] = await this.addressService.getAddress(accountConfigId, [
143
+ addressId,
144
+ ]);
145
+ return known !== undefined;
146
+ };
147
+
148
+ /**
149
+ * Blocking a sender owns the row it writes to. Harvesting is what ordinarily
150
+ * creates it, but a message whose harvest never landed — or whose row was
151
+ * taken by a cascade — still has a sender the user is entitled to block, and
152
+ * a missing row used to abort report-spam before the message ever reached
153
+ * Junk. The id is the harvester's own derivation, so this converges on the
154
+ * one row rather than minting a parallel one.
155
+ *
156
+ * A row that already exists is left exactly as it stands. Reporting spam is
157
+ * evidence about the sender, never about the account's standing record of
158
+ * them: an upsert here would carry whatever the upsert path does on
159
+ * conflict — today a display name chosen by the spammer, and any flag a
160
+ * later sighting-clearing rule hangs off it — into an operation that means
161
+ * the opposite.
162
+ */
163
+ private ensureSenderAddress = async (
164
+ accountConfigId: string,
165
+ from: { addressId: string; normalizedEmail: string; displayName: string },
166
+ ): Promise<void> => {
167
+ const separator = from.normalizedEmail.lastIndexOf("@");
168
+ const localPart = from.normalizedEmail.slice(0, separator);
169
+ const domain = from.normalizedEmail.slice(separator + 1);
170
+ if (separator < 0 || localPart.length === 0 || domain.length === 0) {
171
+ throw new Error(
172
+ `Sender address ${from.normalizedEmail} is not a usable email address`,
173
+ );
174
+ }
175
+ if (await this.senderIsKnown(accountConfigId, from.addressId)) return;
176
+ await this.addressService.upsertAddress({
177
+ addressId: from.addressId,
178
+ accountConfigId,
179
+ localPart,
180
+ domain,
181
+ normalizedEmail: from.normalizedEmail,
182
+ normalizedCompound:
183
+ `${from.displayName.toLowerCase()} ${from.normalizedEmail}`.trim(),
184
+ displayName: from.displayName,
185
+ });
100
186
  };
101
187
 
102
188
  /**
@@ -128,12 +214,24 @@ export class SpamReportService {
128
214
  const { accountConfigId, accountId, messageId, setBy } = params;
129
215
  const now = Date.now();
130
216
 
131
- const from = await this.resolveFromAddress(messageId);
217
+ // Resolved before anything is written: with nowhere to file the report,
218
+ // the operation cannot happen, and a blocked sender plus a `spamReport`
219
+ // stamp on a message still sitting in the inbox is a report that half
220
+ // happened. Fail here and the press changed nothing.
221
+ const junkMailbox =
222
+ await this.mailboxSpecialUseService.findJunkMailbox(accountId);
223
+ if (!junkMailbox) {
224
+ this.log.error({ accountId, messageId }, "No Junk mailbox for account");
225
+ throw new NoJunkMailboxError();
226
+ }
227
+
228
+ const from = await this.resolveFromAddress(accountConfigId, messageId);
132
229
  const account = await this.accountService.get(accountId);
133
230
  const isOwnAddress =
134
231
  from.normalizedEmail.toLowerCase() === account.email.toLowerCase();
135
232
 
136
233
  if (!isOwnAddress) {
234
+ await this.ensureSenderAddress(accountConfigId, from);
137
235
  await this.addressService.mergeFlags(accountConfigId, from.addressId, {
138
236
  blocked: { value: true, setAt: now, setBy },
139
237
  });
@@ -153,12 +251,6 @@ export class SpamReportService {
153
251
  spamReport: { reportedAt: now },
154
252
  });
155
253
 
156
- const junkMailbox =
157
- await this.mailboxSpecialUseService.findJunkMailbox(accountId);
158
- if (!junkMailbox) {
159
- throw new Error(`No Junk mailbox found for account ${accountId}`);
160
- }
161
-
162
254
  const alreadyInJunk = before.mailboxId === junkMailbox.mailboxId;
163
255
 
164
256
  await this.messageMoveService.moveMessage(
@@ -200,10 +292,15 @@ export class SpamReportService {
200
292
  notSpam = async (params: SpamReportParams): Promise<void> => {
201
293
  const { accountConfigId, accountId, messageId } = params;
202
294
 
203
- const from = await this.resolveFromAddress(messageId);
204
- await this.addressService.mergeFlags(accountConfigId, from.addressId, {
205
- blocked: null,
206
- });
295
+ // Unblocking a sender the account has no row for is already true, and
296
+ // minting one here would put an address nothing ever harvested into the
297
+ // address book — the withdrawal of a judgement is not a sighting.
298
+ const from = await this.resolveFromAddress(accountConfigId, messageId);
299
+ if (await this.senderIsKnown(accountConfigId, from.addressId)) {
300
+ await this.addressService.mergeFlags(accountConfigId, from.addressId, {
301
+ blocked: null,
302
+ });
303
+ }
207
304
 
208
305
  const message = await this.messageService.get(messageId);
209
306