@remit/mailbox-service 0.0.35 → 0.0.37

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.35",
3
+ "version": "0.0.37",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -228,14 +228,17 @@ describe("cosineSimilarity", () => {
228
228
  });
229
229
 
230
230
  describe("selectMoveWinner", () => {
231
- const filter = (filterId: string, ruleChangedAt: number): FilterItem =>
232
- ({ filterId, ruleChangedAt }) as FilterItem;
231
+ const filter = (
232
+ filterId: string,
233
+ actionChangedAt: number,
234
+ ruleChangedAt = actionChangedAt,
235
+ ): FilterItem => ({ filterId, actionChangedAt, ruleChangedAt }) as FilterItem;
233
236
 
234
237
  it("returns undefined with no candidates", () => {
235
238
  assert.equal(selectMoveWinner([]), undefined);
236
239
  });
237
240
 
238
- it("picks the most-recently-changed filter", () => {
241
+ it("picks the most-recently action-changed filter", () => {
239
242
  const winner = selectMoveWinner([
240
243
  filter("a", 100),
241
244
  filter("b", 300),
@@ -244,7 +247,7 @@ describe("selectMoveWinner", () => {
244
247
  assert.equal(winner?.filterId, "b");
245
248
  });
246
249
 
247
- it("tie-breaks on filterId when ruleChangedAt is identical", () => {
250
+ it("tie-breaks on filterId when actionChangedAt is identical", () => {
248
251
  const winner = selectMoveWinner([
249
252
  filter("a", 100),
250
253
  filter("c", 100),
@@ -252,6 +255,23 @@ describe("selectMoveWinner", () => {
252
255
  ]);
253
256
  assert.equal(winner?.filterId, "c");
254
257
  });
258
+
259
+ it("ignores a ruleChangedAt-only bump — extending scope/expiry alone must not promote a filter to move-winner (reader #384)", () => {
260
+ // filter A's predicate/action last changed at 10:00 (actionChangedAt).
261
+ // filter B was created at 09:00 and never had its predicate/action
262
+ // touched since, but a user extended its expiry at 11:00 — bumping only
263
+ // its ruleChangedAt (RFC 034 Decision 3.2 / #294), not what it matches or
264
+ // does. B must not out-rank A.
265
+ const filterA = filter("filter-a", 10_00);
266
+ const filterB = filter("filter-b", 9_00, 11_00);
267
+
268
+ const winner = selectMoveWinner([filterA, filterB]);
269
+ assert.equal(
270
+ winner?.filterId,
271
+ "filter-a",
272
+ "A still wins: its actionChangedAt (10:00) beats B's (09:00), even though B's ruleChangedAt (11:00) is later",
273
+ );
274
+ });
255
275
  });
256
276
 
257
277
  describe("buildMatchText", () => {
@@ -134,10 +134,14 @@ export const cosineSimilarity = (
134
134
  };
135
135
 
136
136
  /**
137
- * The move a message ends in when several filters matched: the most-recently
138
- * *changed* filter wins (RFC 034 Decision 3.2), tie-broken on `filterId` for the
139
- * unreachable identical-timestamp case. `ruleChangedAt` not `updatedAt` is
140
- * the signal, so a cosmetic rename never flips an exclusive move.
137
+ * The move a message ends in when several filters matched: the filter whose
138
+ * predicate or action was most recently *changed* wins (RFC 034 Decision 3.2),
139
+ * tie-broken on `filterId` for the unreachable identical-timestamp case.
140
+ * `actionChangedAt` not `ruleChangedAt` is the signal: `ruleChangedAt` also
141
+ * bumps on a scope/expiry-only edit (reader #266), which changes a filter's
142
+ * lifecycle, not what it matches or does, and must not reorder exclusive-move
143
+ * precedence (reader #384). Nor is it `updatedAt`, so a cosmetic rename never
144
+ * flips an exclusive move either.
141
145
  */
142
146
  export const selectMoveWinner = (
143
147
  candidates: readonly FilterItem[],
@@ -148,12 +152,12 @@ export const selectMoveWinner = (
148
152
  winner = candidate;
149
153
  continue;
150
154
  }
151
- if (candidate.ruleChangedAt > winner.ruleChangedAt) {
155
+ if (candidate.actionChangedAt > winner.actionChangedAt) {
152
156
  winner = candidate;
153
157
  continue;
154
158
  }
155
159
  if (
156
- candidate.ruleChangedAt === winner.ruleChangedAt &&
160
+ candidate.actionChangedAt === winner.actionChangedAt &&
157
161
  candidate.filterId > winner.filterId
158
162
  ) {
159
163
  winner = candidate;
@@ -11,7 +11,10 @@ import {
11
11
  MailboxSyncStatus,
12
12
  } from "@remit/domain-enums";
13
13
  import { parseImapAttributes } from "./attribute-mapper.js";
14
- import { MailboxSyncService } from "./mailbox-sync.js";
14
+ import { type MailboxSyncLogger, MailboxSyncService } from "./mailbox-sync.js";
15
+
16
+ const silentLogger: MailboxSyncLogger = { info: () => {}, debug: () => {} };
17
+
15
18
  import type { IImapConnection, ImapNamespaces } from "./types.js";
16
19
 
17
20
  describe("parseImapAttributes – locale invariance (#194)", () => {
@@ -121,7 +124,11 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
121
124
 
122
125
  it("trips cursor_invalid when the STATUS sweep observes a changed UIDVALIDITY", async () => {
123
126
  const { mailboxService, specialUseService, updateCalls } = buildServices(1);
124
- const service = new MailboxSyncService(mailboxService, specialUseService);
127
+ const service = new MailboxSyncService(
128
+ mailboxService,
129
+ specialUseService,
130
+ silentLogger,
131
+ );
125
132
  const connection = buildConnection(2);
126
133
 
127
134
  await service.syncMailboxes({ accountId: "acc-1" }, connection);
@@ -136,7 +143,11 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
136
143
 
137
144
  it("does not write anything when UIDVALIDITY (and everything else) is unchanged", async () => {
138
145
  const { mailboxService, specialUseService, updateCalls } = buildServices(1);
139
- const service = new MailboxSyncService(mailboxService, specialUseService);
146
+ const service = new MailboxSyncService(
147
+ mailboxService,
148
+ specialUseService,
149
+ silentLogger,
150
+ );
140
151
  const connection = buildConnection(1);
141
152
 
142
153
  await service.syncMailboxes({ accountId: "acc-1" }, connection);
@@ -150,7 +161,11 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
150
161
  // the server's current value would step it over every change message sync
151
162
  // had not yet applied.
152
163
  const { mailboxService, specialUseService, updateCalls } = buildServices(1);
153
- const service = new MailboxSyncService(mailboxService, specialUseService);
164
+ const service = new MailboxSyncService(
165
+ mailboxService,
166
+ specialUseService,
167
+ silentLogger,
168
+ );
154
169
  const connection = buildConnection(2, "99999");
155
170
 
156
171
  await service.syncMailboxes({ accountId: "acc-1" }, connection);
@@ -162,7 +177,11 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
162
177
 
163
178
  it("does not sweep-write a mailbox whose only difference is the server mod-sequence", async () => {
164
179
  const { mailboxService, specialUseService, updateCalls } = buildServices(1);
165
- const service = new MailboxSyncService(mailboxService, specialUseService);
180
+ const service = new MailboxSyncService(
181
+ mailboxService,
182
+ specialUseService,
183
+ silentLogger,
184
+ );
166
185
  const connection = buildConnection(1, "4242");
167
186
 
168
187
  await service.syncMailboxes({ accountId: "acc-1" }, connection);
@@ -175,7 +194,11 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
175
194
  1,
176
195
  MailboxCursorState.cursor_invalid,
177
196
  );
178
- const service = new MailboxSyncService(mailboxService, specialUseService);
197
+ const service = new MailboxSyncService(
198
+ mailboxService,
199
+ specialUseService,
200
+ silentLogger,
201
+ );
179
202
  const connection = buildConnection(2);
180
203
 
181
204
  await service.syncMailboxes({ accountId: "acc-1" }, connection);
@@ -273,7 +296,11 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
273
296
  syncStatus: MailboxSyncStatus.pending,
274
297
  },
275
298
  ]);
276
- const service = new MailboxSyncService(mailboxService, specialUseService);
299
+ const service = new MailboxSyncService(
300
+ mailboxService,
301
+ specialUseService,
302
+ silentLogger,
303
+ );
277
304
 
278
305
  const result = await service.syncMailboxes(
279
306
  { accountId: "acc-1" },
@@ -299,7 +326,11 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
299
326
  syncStatus: MailboxSyncStatus.synced,
300
327
  },
301
328
  ]);
302
- const service = new MailboxSyncService(mailboxService, specialUseService);
329
+ const service = new MailboxSyncService(
330
+ mailboxService,
331
+ specialUseService,
332
+ silentLogger,
333
+ );
303
334
 
304
335
  const result = await service.syncMailboxes(
305
336
  { accountId: "acc-1" },
@@ -419,7 +450,11 @@ describe("MailboxSyncService.syncMailboxes — a folder leaving mid-sweep (#339)
419
450
  doomedSyncStatus: syncStatus,
420
451
  });
421
452
  const { connection, statusPaths } = buildConnection(async () => status());
422
- const service = new MailboxSyncService(mailboxService, specialUseService);
453
+ const service = new MailboxSyncService(
454
+ mailboxService,
455
+ specialUseService,
456
+ silentLogger,
457
+ );
423
458
 
424
459
  await service.syncMailboxes({ accountId: "acc-1" }, connection);
425
460
 
@@ -439,7 +474,11 @@ describe("MailboxSyncService.syncMailboxes — a folder leaving mid-sweep (#339)
439
474
  if (path === "Doomed") throw new Error("Mailbox doesn't exist: Doomed");
440
475
  return status();
441
476
  });
442
- const service = new MailboxSyncService(mailboxService, specialUseService);
477
+ const service = new MailboxSyncService(
478
+ mailboxService,
479
+ specialUseService,
480
+ silentLogger,
481
+ );
443
482
 
444
483
  await assert.doesNotReject(
445
484
  service.syncMailboxes({ accountId: "acc-1" }, connection),
@@ -460,7 +499,11 @@ describe("MailboxSyncService.syncMailboxes — a folder leaving mid-sweep (#339)
460
499
  if (path === "Doomed") throw new Error("connection reset by peer");
461
500
  return status();
462
501
  });
463
- const service = new MailboxSyncService(mailboxService, specialUseService);
502
+ const service = new MailboxSyncService(
503
+ mailboxService,
504
+ specialUseService,
505
+ silentLogger,
506
+ );
464
507
 
465
508
  await assert.rejects(
466
509
  service.syncMailboxes({ accountId: "acc-1" }, connection),
@@ -77,19 +77,45 @@ export interface SyncAccountInfo {
77
77
  accountId: string;
78
78
  }
79
79
 
80
+ /**
81
+ * Logger interface for MailboxSyncService.
82
+ *
83
+ * `info` is reserved here for the three mailbox lifecycle events — created,
84
+ * updated, deleted — and is the argued exception to `plugins/no-logger-info.grit`
85
+ * that the rest of this repo writes as a suppression. (The plugin matches the
86
+ * bare `logger` identifier, so it does not see an injected logger; the reasoning
87
+ * is written out instead of being silently absent.) A folder disappearing from
88
+ * someone's mail is destructive and irreversible from this service's side, and
89
+ * the only record that this process did it is the line it writes. That must not
90
+ * sit below the default threshold.
91
+ *
92
+ * Everything else this service has to say — a special-use flag it synced, a
93
+ * duplicate folder it skipped — is a routine trace and goes to `debug`.
94
+ */
95
+ export interface MailboxSyncLogger {
96
+ info(obj: Record<string, unknown>, msg: string): void;
97
+ debug(obj: Record<string, unknown>, msg: string): void;
98
+ }
99
+
80
100
  /**
81
101
  * Service for synchronizing mailbox metadata between IMAP and the data backend.
82
102
  */
83
103
  export class MailboxSyncService {
84
104
  private mailboxService: IMailboxRepository;
85
105
  private specialUseService: IMailboxSpecialUseRepository;
106
+ private log: MailboxSyncLogger;
86
107
 
108
+ // Required, with no no-op default: the lines below are the only record that a
109
+ // mailbox was created or destroyed, and a default would let the next call site
110
+ // lose them with no error and no failing test.
87
111
  constructor(
88
112
  mailboxService: IMailboxRepository,
89
113
  specialUseService: IMailboxSpecialUseRepository,
114
+ logger: MailboxSyncLogger,
90
115
  ) {
91
116
  this.mailboxService = mailboxService;
92
117
  this.specialUseService = specialUseService;
118
+ this.log = logger;
93
119
  }
94
120
 
95
121
  /**
@@ -146,8 +172,13 @@ export class MailboxSyncService {
146
172
  account.accountId,
147
173
  existing.mailboxId,
148
174
  );
149
- console.info(
150
- `Deleted non-selectable mailbox: ${existing.mailboxId} (${existing.fullPath})`,
175
+ this.log.info(
176
+ {
177
+ mailboxId: existing.mailboxId,
178
+ fullPath: existing.fullPath,
179
+ reason: "non-selectable",
180
+ },
181
+ "Deleted mailbox",
151
182
  );
152
183
  result.deleted++;
153
184
  }
@@ -164,8 +195,13 @@ export class MailboxSyncService {
164
195
  account.accountId,
165
196
  existing.mailboxId,
166
197
  );
167
- console.info(
168
- `Deleted duplicate special-use mailbox: ${existing.mailboxId} (${existing.fullPath})`,
198
+ this.log.info(
199
+ {
200
+ mailboxId: existing.mailboxId,
201
+ fullPath: existing.fullPath,
202
+ reason: "duplicate-special-use",
203
+ },
204
+ "Deleted mailbox",
169
205
  );
170
206
  result.deleted++;
171
207
  }
@@ -238,8 +274,13 @@ export class MailboxSyncService {
238
274
  // pending rows to the create/rename flow that owns them.
239
275
  if (existing.syncStatus === MailboxSyncStatus.pending) continue;
240
276
  await this.mailboxService.delete(account.accountId, existing.mailboxId);
241
- console.info(
242
- `Deleted mailbox: ${existing.mailboxId} (${existing.fullPath})`,
277
+ this.log.info(
278
+ {
279
+ mailboxId: existing.mailboxId,
280
+ fullPath: existing.fullPath,
281
+ reason: "not-on-server",
282
+ },
283
+ "Deleted mailbox",
243
284
  );
244
285
  result.deleted++;
245
286
  }
@@ -419,12 +460,18 @@ export class MailboxSyncService {
419
460
  mailbox.mailboxId,
420
461
  parsed.specialUse,
421
462
  );
422
- console.info(
423
- `Created mailbox: ${mailbox.mailboxId} (${mailboxInfo.fullPath}) [special-use: ${parsed.specialUse.join(", ")}]`,
463
+ this.log.info(
464
+ {
465
+ mailboxId: mailbox.mailboxId,
466
+ fullPath: mailboxInfo.fullPath,
467
+ specialUse: parsed.specialUse,
468
+ },
469
+ "Created mailbox",
424
470
  );
425
471
  } else {
426
- console.info(
427
- `Created mailbox: ${mailbox.mailboxId} (${mailboxInfo.fullPath})`,
472
+ this.log.info(
473
+ { mailboxId: mailbox.mailboxId, fullPath: mailboxInfo.fullPath },
474
+ "Created mailbox",
428
475
  );
429
476
  }
430
477
 
@@ -504,8 +551,13 @@ export class MailboxSyncService {
504
551
  `specialUse: [${(existing.specialUse ?? []).join(",")}] -> [${parsed.specialUse.join(",")}]`,
505
552
  );
506
553
 
507
- console.info(
508
- `Updating mailbox: ${existing.mailboxId} (${mailboxInfo.fullPath}) [${changes.join(", ")}]`,
554
+ this.log.info(
555
+ {
556
+ mailboxId: existing.mailboxId,
557
+ fullPath: mailboxInfo.fullPath,
558
+ changes,
559
+ },
560
+ "Updating mailbox",
509
561
  );
510
562
 
511
563
  // Update mailbox with fresh status. ElectroDB rejects empty sets, so we
@@ -557,8 +609,9 @@ export class MailboxSyncService {
557
609
 
558
610
  if (parsed.specialUse.length > 0) {
559
611
  await this.specialUseService.createMany(mailboxId, parsed.specialUse);
560
- console.info(
561
- `Synced special-use for ${mailboxInfo.fullPath}: ${parsed.specialUse.join(", ")}`,
612
+ this.log.debug(
613
+ { fullPath: mailboxInfo.fullPath, specialUse: parsed.specialUse },
614
+ "Synced special-use",
562
615
  );
563
616
  }
564
617
  };
@@ -619,8 +672,13 @@ export class MailboxSyncService {
619
672
  }
620
673
 
621
674
  // This is a duplicate - another folder has the attribute
622
- console.info(
623
- `Skipping duplicate folder "${mailbox.fullPath}" - "${claimedPath}" has \\${expectedSpecialUse} attribute`,
675
+ this.log.debug(
676
+ {
677
+ fullPath: mailbox.fullPath,
678
+ claimedPath,
679
+ specialUse: expectedSpecialUse,
680
+ },
681
+ "Skipping duplicate folder",
624
682
  );
625
683
  return true;
626
684
  };