@remit/mailbox-service 0.0.94 → 0.0.96

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.
@@ -1,8 +1,10 @@
1
1
  import type { MessageItem } from "@remit/data-ports";
2
- import { SenderTrust } from "@remit/domain-enums";
2
+ import { SenderOverride, SenderTrust } from "@remit/domain-enums";
3
3
 
4
4
  type SenderTrustValue = (typeof SenderTrust)[keyof typeof SenderTrust];
5
5
 
6
+ type SenderOverrideValue = (typeof SenderOverride)[keyof typeof SenderOverride];
7
+
6
8
  export type FolderPlacement = "inbox" | "junk" | "other";
7
9
 
8
10
  export type PlacementAction = "move-to-inbox" | "move-to-junk" | "leave";
@@ -39,7 +41,7 @@ export interface SenderBlockedSignal {
39
41
  * fixed, arbitrary order: `blocked` before `vip` before `wellknown` — a
40
42
  * determinism backstop, not a meaningful signal.
41
43
  */
42
- export const resolveBlockedVsTrust = (
44
+ const resolveBlockedVsTrust = (
43
45
  trust: SenderTrustSignal,
44
46
  blocked: SenderBlockedSignal,
45
47
  ): { senderTrust: SenderTrustValue; senderBlocked: boolean } => {
@@ -60,6 +62,32 @@ export const resolveBlockedVsTrust = (
60
62
  return { senderTrust: trust.trust, senderBlocked: false };
61
63
  };
62
64
 
65
+ /**
66
+ * Resolve the two axes {@link classifyPlacement} takes: the trust signal, and
67
+ * the user's standing instruction about placement.
68
+ *
69
+ * `blocked` and `neverSpam` are two directly contradictory instructions and
70
+ * cannot both stand — `AddressRepository.mergeFlags` drops the loser at write
71
+ * time. Reading a row that carries both anyway (written before that invariant)
72
+ * resolves to `Blocked`, the same direction the `blocked`/`vip` same-second tie
73
+ * already breaks in: the demote is the recoverable mistake.
74
+ */
75
+ export const resolveSenderPlacement = (
76
+ trust: SenderTrustSignal,
77
+ blocked: SenderBlockedSignal,
78
+ neverSpam: boolean,
79
+ ): {
80
+ senderTrust: SenderTrustValue;
81
+ senderOverride: SenderOverrideValue;
82
+ } => {
83
+ const { senderTrust, senderBlocked } = resolveBlockedVsTrust(trust, blocked);
84
+ if (senderBlocked)
85
+ return { senderTrust, senderOverride: SenderOverride.Blocked };
86
+ if (neverSpam)
87
+ return { senderTrust, senderOverride: SenderOverride.NeverSpam };
88
+ return { senderTrust, senderOverride: SenderOverride.None };
89
+ };
90
+
63
91
  /**
64
92
  * Tier 0 deterministic placement verdict (RFC 031, "Confident moves").
65
93
  *
@@ -69,17 +97,32 @@ export const resolveBlockedVsTrust = (
69
97
  * recall-biased — a confident move only fires when cheap, deterministic signals
70
98
  * agree; everything else is left in place for a later LLM tier.
71
99
  *
72
- * `senderBlocked` (RFC 039 Decision 3) is a confident demote independent of
73
- * every DKIM/DMARC/provider signal below — a user's explicit block is not a
74
- * heuristic. Already-tie-broken against `vip`/`wellknown` by
75
- * {@link resolveBlockedVsTrust} in the caller; this function itself does not
100
+ * `senderOverride` (RFC 039 Decision 3, issue #605) is the user's standing
101
+ * instruction about this sender's placement, and it outranks every
102
+ * DKIM/DMARC/provider signal below — an explicit block or never-spam grant is
103
+ * not a heuristic. Already-tie-broken against `vip`/`wellknown` by
104
+ * {@link resolveSenderPlacement} in the caller; this function itself does not
76
105
  * compare `setAt`.
106
+ *
107
+ * `NeverSpam` is checked above the `missing-signals` return and above the
108
+ * `providerSpam.classified` gate on purpose: the senders who need the grant are
109
+ * the ones the provider rated clean and whose domain publishes no DMARC record,
110
+ * so a check inside the rescue branch below would never fire for them. It
111
+ * rescues only from `junk` — a message a standing filter put in a user folder
112
+ * is not a spam question.
113
+ *
114
+ * It refuses outright on a `dmarc === "Fail"` or a DKIM mismatch. The demote
115
+ * branch below is reached only from `inbox`, so a spoof already sitting in Junk
116
+ * would never meet it: without this guard the grant would launder mail out of
117
+ * Junk on exactly the two signals that identify someone impersonating the
118
+ * address the user granted. A domain that publishes no DMARC record never
119
+ * scores `Fail`, so the case the grant exists for is untouched.
77
120
  */
78
121
  export const classifyPlacement = (
79
122
  message: MessageItem,
80
123
  placement: FolderPlacement,
81
124
  senderTrust: SenderTrustValue,
82
- senderBlocked: boolean,
125
+ senderOverride: SenderOverrideValue,
83
126
  ): PlacementVerdict => {
84
127
  if (message.movedByRemit === true) {
85
128
  return {
@@ -89,7 +132,7 @@ export const classifyPlacement = (
89
132
  };
90
133
  }
91
134
 
92
- if (senderBlocked && placement !== "junk") {
135
+ if (senderOverride === SenderOverride.Blocked && placement !== "junk") {
93
136
  return {
94
137
  action: "move-to-junk",
95
138
  confidence: "confident",
@@ -97,6 +140,19 @@ export const classifyPlacement = (
97
140
  };
98
141
  }
99
142
 
143
+ if (
144
+ senderOverride === SenderOverride.NeverSpam &&
145
+ placement === "junk" &&
146
+ message.authResult?.dmarc !== "Fail" &&
147
+ message.authenticity?.dkimMismatch !== true
148
+ ) {
149
+ return {
150
+ action: "move-to-inbox",
151
+ confidence: "confident",
152
+ reasons: ["sender=never-spam"],
153
+ };
154
+ }
155
+
100
156
  if (!message.providerSpam || !message.authResult) {
101
157
  return {
102
158
  action: "leave",
@@ -265,11 +265,18 @@ const buildHarness = (options: HarnessOptions): Harness => {
265
265
  },
266
266
  ) as IAddressRepository;
267
267
 
268
+ // The rows sync reads back before it saves, to ask whether a message sighted
269
+ // here has left the folder its row points at (#1146). This harness stores
270
+ // none, so nothing is contested and the probe issues no IMAP.
271
+ const messageRepository = {
272
+ get: async () => [],
273
+ } as unknown as IMessageRepository;
274
+
268
275
  const service = new MessageSyncService(
269
276
  connectionFactory,
270
277
  mailboxService,
271
278
  noFolderRoles,
272
- {} as IMessageRepository,
279
+ messageRepository,
273
280
  {} as IEnvelopeRepository,
274
281
  addressRepository,
275
282
  threadMessageService,
@@ -174,6 +174,13 @@ const buildHarness = (options: {
174
174
  },
175
175
  } satisfies IQuarantineRepository;
176
176
 
177
+ // The rows sync reads back before it saves, to ask whether a message sighted
178
+ // here has left the folder its row points at (#1146). This harness stores
179
+ // none, so nothing is contested and the probe issues no IMAP.
180
+ const messageRepository = {
181
+ get: async () => [],
182
+ } as unknown as IMessageRepository;
183
+
177
184
  const service = new MessageSyncService(
178
185
  {
179
186
  getConnection: () => connection,
@@ -181,7 +188,7 @@ const buildHarness = (options: {
181
188
  } as ManagedConnectionFactory,
182
189
  mailboxService,
183
190
  noFolderRoles,
184
- {} as IMessageRepository,
191
+ messageRepository,
185
192
  {} as IEnvelopeRepository,
186
193
  {} as IAddressRepository,
187
194
  threadMessageService,
@@ -11,13 +11,11 @@ import type {
11
11
  ThreadMessageItem,
12
12
  UpdateThreadMessageInput,
13
13
  } from "@remit/data-ports";
14
+ import { deriveMessageId } from "@remit/data-ports/id";
14
15
  import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
15
16
  import type { ManagedConnectionFactory } from "./connection-factory.js";
16
- import {
17
- type AccountFolderRoles,
18
- MessageSyncService,
19
- repointsOnSighting,
20
- } from "./message-sync.js";
17
+ import { type DepartureVerdicts, placementKey } from "./external-move.js";
18
+ import { type AccountFolderRoles, MessageSyncService } from "./message-sync.js";
21
19
  import { folderRoles, NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
22
20
  import type { ImapEnvelope, ImapMessage } from "./types.js";
23
21
 
@@ -85,8 +83,22 @@ interface Observed {
85
83
  threadUpdates: UpdateThreadMessageInput[];
86
84
  reconciled: string[];
87
85
  owned: boolean;
86
+ unresolved: boolean;
88
87
  }
89
88
 
89
+ /**
90
+ * What `confirmDepartures` had answered about the stored placement by the time
91
+ * the batch was saved (#1146).
92
+ *
93
+ * - `departed` — the source folder no longer holds it: a move another client made.
94
+ * - `held` — the source folder still holds it: the labelled message, in two
95
+ * folders at once.
96
+ * - `none` — no verdict at all: the source could not be asked this round.
97
+ * - `stale` — a verdict, but for a placement this row no longer has, because it
98
+ * moved between the probe and the save.
99
+ */
100
+ type Verdict = "departed" | "held" | "none" | "stale";
101
+
90
102
  /**
91
103
  * Sync one message out of `sighting`, against a database that already holds it
92
104
  * under `stored`. The account's Junk folder is `JUNK` and its Trash is `TRASH`,
@@ -97,12 +109,14 @@ const sync = async (
97
109
  stored: MessageItem,
98
110
  threadRow: ThreadMessageItem | null = threadRowIn(INBOX),
99
111
  copiesHere: MessageItem[] = [],
112
+ verdict: Verdict = "departed",
100
113
  ): Promise<Observed> => {
101
114
  const observed: Observed = {
102
115
  repointedTo: [],
103
116
  threadUpdates: [],
104
117
  reconciled: [],
105
118
  owned: false,
119
+ unresolved: false,
106
120
  };
107
121
 
108
122
  const messageService = {
@@ -164,6 +178,19 @@ const sync = async (
164
178
  envelope,
165
179
  } as unknown as ImapMessage;
166
180
 
181
+ const answeredFor = placementKey(
182
+ deriveMessageId("acct-1", envelope.messageId),
183
+ stored.mailboxId,
184
+ verdict === "stale" ? stored.uid + 1 : stored.uid,
185
+ );
186
+ const departures: DepartureVerdicts = {
187
+ departed:
188
+ verdict === "held" || verdict === "none"
189
+ ? new Set()
190
+ : new Set([answeredFor]),
191
+ settled: verdict === "none" ? new Set() : new Set([answeredFor]),
192
+ };
193
+
167
194
  const result = (await (
168
195
  service as unknown as {
169
196
  saveMessage: (
@@ -172,15 +199,24 @@ const sync = async (
172
199
  accountConfigId: string,
173
200
  msg: ImapMessage,
174
201
  roles: AccountFolderRoles,
175
- ) => Promise<{ owned: boolean }>;
202
+ departures: DepartureVerdicts,
203
+ ) => Promise<{ owned: boolean; unresolvedSighting: boolean }>;
176
204
  }
177
- ).saveMessage(sighting, "acct-1", "cfg-1", msg, {
178
- junkMailboxId: JUNK.mailboxId,
179
- trashMailboxId: TRASH.mailboxId,
180
- configJunkRoles: NO_JUNK_ROLES,
181
- })) as { owned: boolean };
205
+ ).saveMessage(
206
+ sighting,
207
+ "acct-1",
208
+ "cfg-1",
209
+ msg,
210
+ {
211
+ junkMailboxId: JUNK.mailboxId,
212
+ trashMailboxId: TRASH.mailboxId,
213
+ configJunkRoles: NO_JUNK_ROLES,
214
+ },
215
+ departures,
216
+ )) as { owned: boolean; unresolvedSighting: boolean };
182
217
 
183
218
  observed.owned = result.owned;
219
+ observed.unresolved = result.unresolvedSighting;
184
220
  return observed;
185
221
  };
186
222
 
@@ -227,6 +263,87 @@ describe("which folder a message the database already holds lives in", () => {
227
263
  assert.equal(observed.owned, false);
228
264
  });
229
265
 
266
+ /**
267
+ * The reported bug (#1146): a message that legitimately sits in two real
268
+ * folders — a Gmail user label, a Sieve `fileinto` beside a `keep`, a Sent
269
+ * copy a list echoes back — was re-pointed out of the Inbox by whichever
270
+ * folder the round enumerated last.
271
+ */
272
+ it("leaves a labelled message in the folder that still holds it", async () => {
273
+ const observed = await sync(
274
+ mailboxAt("Receipts"),
275
+ storedIn(INBOX),
276
+ threadRowIn(INBOX),
277
+ [],
278
+ "held",
279
+ );
280
+
281
+ assert.deepEqual(observed.repointedTo, []);
282
+ assert.deepEqual(observed.threadUpdates, []);
283
+ assert.equal(observed.owned, false);
284
+ });
285
+
286
+ /**
287
+ * A decided sighting is decided for good: the message is in two folders and
288
+ * asking again next round would get the same answer. Only an UNDECIDED one
289
+ * comes back, so a settled decline must not hold the watermark.
290
+ */
291
+ it("finishes with a labelled message rather than holding the watermark for it", async () => {
292
+ const observed = await sync(
293
+ mailboxAt("Receipts"),
294
+ storedIn(INBOX),
295
+ threadRowIn(INBOX),
296
+ [],
297
+ "held",
298
+ );
299
+
300
+ assert.equal(observed.unresolved, false);
301
+ });
302
+
303
+ it("holds the watermark when the source folder could not be asked", async () => {
304
+ const observed = await sync(
305
+ JUNK,
306
+ storedIn(INBOX),
307
+ threadRowIn(INBOX),
308
+ [],
309
+ "none",
310
+ );
311
+
312
+ assert.deepEqual(observed.repointedTo, []);
313
+ assert.equal(observed.unresolved, true);
314
+ });
315
+
316
+ /**
317
+ * The probe reads the row outside the transaction that acts on it, so a user
318
+ * move landing in between leaves a verdict about a placement this row no
319
+ * longer has. Spending it would re-point the row against an answer reached
320
+ * for somewhere else.
321
+ */
322
+ it("spends no verdict reached for a placement the row has since left", async () => {
323
+ const observed = await sync(
324
+ JUNK,
325
+ storedIn(INBOX),
326
+ threadRowIn(INBOX),
327
+ [],
328
+ "stale",
329
+ );
330
+
331
+ assert.deepEqual(observed.repointedTo, []);
332
+ assert.equal(observed.unresolved, true);
333
+ });
334
+
335
+ it("holds the watermark for nothing when the row is already ours", async () => {
336
+ const observed = await sync(
337
+ INBOX,
338
+ storedIn(INBOX),
339
+ threadRowIn(INBOX),
340
+ [],
341
+ "none",
342
+ );
343
+
344
+ assert.equal(observed.unresolved, false);
345
+ });
346
+
230
347
  it("declines a sighting in a folder that copies every message", async () => {
231
348
  const observed = await sync(ALL_MAIL, storedIn(INBOX));
232
349
 
@@ -300,95 +417,3 @@ describe("what a re-pointed message does to its sender's standing", () => {
300
417
  assert.deepEqual(observed.reconciled, []);
301
418
  });
302
419
  });
303
-
304
- describe("repointsOnSighting", () => {
305
- it("refuses a Gmail virtual folder the server never flagged", () => {
306
- assert.equal(
307
- repointsOnSighting(mailboxAt("[Gmail]/All Mail"), storedIn(INBOX)),
308
- false,
309
- );
310
- });
311
-
312
- it("accepts a folder the user named after a virtual one", () => {
313
- assert.equal(
314
- repointsOnSighting(mailboxAt("Starred ideas"), storedIn(INBOX)),
315
- true,
316
- );
317
- });
318
-
319
- it("accepts an ordinary inbound row the sync path left pending", () => {
320
- assert.equal(
321
- repointsOnSighting(
322
- JUNK,
323
- storedIn(INBOX, { syncStatus: MessageSyncStatus.pending }),
324
- ),
325
- true,
326
- );
327
- });
328
-
329
- it("accepts a row a settled mutation marked synced", () => {
330
- assert.equal(
331
- repointsOnSighting(
332
- JUNK,
333
- storedIn(INBOX, { syncStatus: MessageSyncStatus.synced }),
334
- ),
335
- true,
336
- );
337
- });
338
-
339
- /**
340
- * The row `abandonDelete` hands back: reader refused the delete, put the
341
- * message back where the server still has it, and nothing else is coming
342
- * for the row. Reader shares its mailboxes, so the user moving that same
343
- * message in another client is ordinary — and before R3 the sighting was
344
- * refused on `syncStatus` alone and the row never followed the move.
345
- */
346
- it("accepts a row whose delete was abandoned, so a move made elsewhere still lands", () => {
347
- assert.equal(
348
- repointsOnSighting(
349
- JUNK,
350
- storedIn(INBOX, {
351
- status: MessageStatus.active,
352
- syncStatus: MessageSyncStatus.abandoned,
353
- }),
354
- ),
355
- true,
356
- );
357
- });
358
-
359
- it("accepts a row left `failed` by a transient attempt that has since settled", () => {
360
- assert.equal(
361
- repointsOnSighting(
362
- JUNK,
363
- storedIn(INBOX, { syncStatus: MessageSyncStatus.failed }),
364
- ),
365
- true,
366
- );
367
- });
368
-
369
- it("refuses a row whose own move is still in flight", () => {
370
- assert.equal(
371
- repointsOnSighting(
372
- JUNK,
373
- storedIn(INBOX, {
374
- status: MessageStatus.moving,
375
- syncStatus: MessageSyncStatus.pending,
376
- }),
377
- ),
378
- false,
379
- );
380
- });
381
-
382
- it("refuses a row whose own delete is still in flight", () => {
383
- assert.equal(
384
- repointsOnSighting(
385
- JUNK,
386
- storedIn(INBOX, {
387
- status: MessageStatus.deleting,
388
- syncStatus: MessageSyncStatus.pending,
389
- }),
390
- ),
391
- false,
392
- );
393
- });
394
- });