@remit/mailbox-service 0.0.56 → 0.0.58

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.58",
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,
@@ -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,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