@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 +1 -1
- package/src/filters/match.test.ts +24 -4
- package/src/filters/match.ts +10 -6
- package/src/mailbox-sync.test.ts +54 -11
- package/src/mailbox-sync.ts +74 -16
package/package.json
CHANGED
|
@@ -228,14 +228,17 @@ describe("cosineSimilarity", () => {
|
|
|
228
228
|
});
|
|
229
229
|
|
|
230
230
|
describe("selectMoveWinner", () => {
|
|
231
|
-
const filter = (
|
|
232
|
-
|
|
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
|
|
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", () => {
|
package/src/filters/match.ts
CHANGED
|
@@ -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
|
|
138
|
-
* *changed*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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.
|
|
155
|
+
if (candidate.actionChangedAt > winner.actionChangedAt) {
|
|
152
156
|
winner = candidate;
|
|
153
157
|
continue;
|
|
154
158
|
}
|
|
155
159
|
if (
|
|
156
|
-
candidate.
|
|
160
|
+
candidate.actionChangedAt === winner.actionChangedAt &&
|
|
157
161
|
candidate.filterId > winner.filterId
|
|
158
162
|
) {
|
|
159
163
|
winner = candidate;
|
package/src/mailbox-sync.test.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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),
|
package/src/mailbox-sync.ts
CHANGED
|
@@ -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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
423
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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
|
-
|
|
508
|
-
|
|
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
|
-
|
|
561
|
-
|
|
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
|
-
|
|
623
|
-
|
|
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
|
};
|