@vex-chat/libvex 6.4.1 → 6.5.1

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/src/Client.ts CHANGED
@@ -149,6 +149,26 @@ export class DeviceApprovalRequiredError extends Error {
149
149
  }
150
150
  }
151
151
 
152
+ function cloneNullableBytes(value: null | Uint8Array): null | Uint8Array {
153
+ return value ? new Uint8Array(value) : null;
154
+ }
155
+
156
+ function cloneSessionCrypto(session: SessionCrypto): SessionCrypto {
157
+ return {
158
+ ...session,
159
+ CKr: cloneNullableBytes(session.CKr),
160
+ CKs: cloneNullableBytes(session.CKs),
161
+ DHr: cloneNullableBytes(session.DHr),
162
+ DHsPrivate: new Uint8Array(session.DHsPrivate),
163
+ DHsPublic: new Uint8Array(session.DHsPublic),
164
+ fingerprint: new Uint8Array(session.fingerprint),
165
+ publicKey: new Uint8Array(session.publicKey),
166
+ RK: new Uint8Array(session.RK),
167
+ SK: new Uint8Array(session.SK),
168
+ skippedKeys: { ...session.skippedKeys },
169
+ };
170
+ }
171
+
152
172
  function debugLibvexDm(
153
173
  msg: string,
154
174
  data?: Record<string, boolean | null | number | string | undefined>,
@@ -156,6 +176,9 @@ function debugLibvexDm(
156
176
  if (!libvexDebugDmEnabled()) {
157
177
  return;
158
178
  }
179
+ if (isHeartbeatDebugMessage(msg, data) && libvexDebugLevel() !== "trace") {
180
+ return;
181
+ }
159
182
  const payload = data ? `${msg} ${JSON.stringify(data)}` : msg;
160
183
  // eslint-disable-next-line no-console -- gated by LIBVEX_DEBUG_DM; remove when debugging is done
161
184
  console.error(`[libvex:debug-dm] ${payload}`);
@@ -168,6 +191,14 @@ function ignoreSocketTeardown(err: unknown): void {
168
191
  throw err;
169
192
  }
170
193
 
194
+ function isHeartbeatDebugMessage(
195
+ msg: string,
196
+ data?: Record<string, boolean | null | number | string | undefined>,
197
+ ): boolean {
198
+ if (/\b(?:ping|pong)\b/i.test(msg)) return true;
199
+ return data?.["type"] === "ping" || data?.["type"] === "pong";
200
+ }
201
+
171
202
  function isRecord(x: unknown): x is Record<string, unknown> {
172
203
  return typeof x === "object" && x !== null;
173
204
  }
@@ -202,6 +233,26 @@ function libvexDebugDmEnabled(): boolean {
202
233
  }
203
234
  }
204
235
 
236
+ function libvexDebugLevel(): "debug" | "trace" {
237
+ try {
238
+ const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
239
+ if (!g) return "debug";
240
+ const proc: unknown = typeof g.get === "function" ? g.get() : g.value;
241
+ if (typeof proc !== "object" || proc === null) return "debug";
242
+ const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
243
+ if (!envDesc) return "debug";
244
+ const env: unknown =
245
+ typeof envDesc.get === "function" ? envDesc.get() : envDesc.value;
246
+ if (typeof env !== "object" || env === null) return "debug";
247
+ const value = String(
248
+ Reflect.get(env, "LIBVEX_DEBUG_LEVEL") ?? "",
249
+ ).toLowerCase();
250
+ return value === "trace" || value === "2" ? "trace" : "debug";
251
+ } catch {
252
+ return "debug";
253
+ }
254
+ }
255
+
205
256
  function sleep(ms: number): Promise<void> {
206
257
  return new Promise((resolve) => setTimeout(resolve, ms));
207
258
  }
@@ -1299,16 +1350,17 @@ export class Client {
1299
1350
 
1300
1351
  private readonly dbPath: string;
1301
1352
 
1353
+ private readonly decryptFailureCounts = new Map<string, number>();
1354
+
1302
1355
  private device?: Device;
1303
1356
 
1304
1357
  private deviceRecords: Record<string, Device> = {};
1305
-
1306
1358
  // ── Event subscription (composition over inheritance) ───────────────
1307
1359
  private readonly emitter = new EventEmitter<ClientEvents>();
1360
+
1308
1361
  private fetchingMail: boolean = false;
1309
1362
 
1310
1363
  private firstMailFetch = true;
1311
-
1312
1364
  private readonly forwarded = new Set<string>();
1313
1365
  private readonly host: string;
1314
1366
  private readonly http: AxiosInstance;
@@ -1316,13 +1368,13 @@ export class Client {
1316
1368
  private readonly httpAbortController = new AbortController();
1317
1369
  private readonly idKeys: KeyPair | null;
1318
1370
  private isAlive: boolean = true;
1319
- private localMessageRetentionDays: number;
1320
1371
 
1372
+ private localMessageRetentionDays: number;
1321
1373
  private localRetentionPurgeTimer: null | ReturnType<typeof setInterval> =
1322
1374
  null;
1323
1375
  private readonly mailInterval?: NodeJS.Timeout;
1324
- private manuallyClosing: boolean = false;
1325
1376
 
1377
+ private manuallyClosing: boolean = false;
1326
1378
  /**
1327
1379
  * Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
1328
1380
  * Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
@@ -1336,19 +1388,19 @@ export class Client {
1336
1388
  and finally falls back to username. */
1337
1389
  /** Negative cache for user lookups that returned 404. TTL = 30 minutes. */
1338
1390
  private readonly notFoundUsers = new Map<string, number>();
1391
+
1339
1392
  private readonly options?: ClientOptions | undefined;
1340
1393
 
1341
1394
  private pingInterval: null | ReturnType<typeof setTimeout> = null;
1342
-
1343
1395
  /**
1344
1396
  * Bumped when the WebSocket is torn down and re-opened so the previous
1345
1397
  * `postAuth` loop exits instead of overlapping a new one.
1346
1398
  */
1347
1399
  private postAuthVersion = 0;
1400
+
1348
1401
  private readonly prefixes:
1349
1402
  | { HTTP: "http://"; WS: "ws://" }
1350
1403
  | { HTTP: "https://"; WS: "wss://" };
1351
-
1352
1404
  private reading: boolean = false;
1353
1405
  private retentionPurgeDebounce: null | ReturnType<typeof setTimeout> = null;
1354
1406
  private readonly seenMailIDs: Set<string> = new Set();
@@ -2179,10 +2231,27 @@ export class Client {
2179
2231
  }
2180
2232
 
2181
2233
  private acknowledgeInboundMail(mail: MailWS): void {
2234
+ this.decryptFailureCounts.delete(mail.mailID);
2182
2235
  this.seenMailIDs.add(mail.mailID);
2183
2236
  this.sendReceipt(new Uint8Array(mail.nonce));
2184
2237
  }
2185
2238
 
2239
+ private acknowledgeRepeatedDecryptFailure(
2240
+ mail: MailWS,
2241
+ count: number,
2242
+ ): void {
2243
+ if (count < 2) return;
2244
+ if (libvexDebugDmEnabled()) {
2245
+ debugLibvexDm("readMail: acknowledge repeated decrypt failure", {
2246
+ attempts: count,
2247
+ mailID: mail.mailID,
2248
+ sender: mail.sender,
2249
+ thisDevice: this.getDevice().deviceID,
2250
+ });
2251
+ }
2252
+ this.acknowledgeInboundMail(mail);
2253
+ }
2254
+
2186
2255
  private async approveDeviceRequest(requestID: string): Promise<Device> {
2187
2256
  const req = await this.getDeviceRegistrationRequest(requestID);
2188
2257
  if (!req) {
@@ -2615,6 +2684,7 @@ export class Client {
2615
2684
  private async deletePermission(permissionID: string): Promise<void> {
2616
2685
  await this.http.delete(this.getHost() + "/permission/" + permissionID);
2617
2686
  }
2687
+
2618
2688
  private async deleteServer(serverID: string): Promise<void> {
2619
2689
  await this.http.delete(this.getHost() + "/server/" + serverID);
2620
2690
  }
@@ -2631,7 +2701,6 @@ export class Client {
2631
2701
  }
2632
2702
  return "";
2633
2703
  }
2634
-
2635
2704
  /**
2636
2705
  * Gets a list of permissions for a server.
2637
2706
  *
@@ -3140,8 +3209,6 @@ export class Client {
3140
3209
  return decodeAxios(UserArrayCodec, res.data);
3141
3210
  }
3142
3211
 
3143
- // ── Passkeys ────────────────────────────────────────────────────────
3144
-
3145
3212
  private async handleNotify(msg: NotifyMsg) {
3146
3213
  switch (msg.event) {
3147
3214
  case "deviceRequest": {
@@ -3210,6 +3277,8 @@ export class Client {
3210
3277
  this.emitter.emit("ready");
3211
3278
  }
3212
3279
 
3280
+ // ── Passkeys ────────────────────────────────────────────────────────
3281
+
3213
3282
  private initSocket() {
3214
3283
  try {
3215
3284
  if (!this.token) {
@@ -3405,7 +3474,11 @@ export class Client {
3405
3474
  if (this.isManualCloseInFlight()) {
3406
3475
  return;
3407
3476
  }
3408
- if (message.direction === "outgoing" && !message.forward) {
3477
+ if (
3478
+ message.direction === "outgoing" &&
3479
+ !message.forward &&
3480
+ message.group === null
3481
+ ) {
3409
3482
  void this.forward(message);
3410
3483
  }
3411
3484
 
@@ -3650,6 +3723,23 @@ export class Client {
3650
3723
  return;
3651
3724
  }
3652
3725
 
3726
+ if (await this.database.hasMessage(mail.mailID)) {
3727
+ if (libvexDebugDmEnabled()) {
3728
+ try {
3729
+ debugLibvexDm("readMail: skip (stored mailID)", {
3730
+ mailID: mail.mailID,
3731
+ thisDevice: this.getDevice().deviceID,
3732
+ });
3733
+ } catch {
3734
+ debugLibvexDm("readMail: skip (stored mailID)", {
3735
+ mailID: mail.mailID,
3736
+ });
3737
+ }
3738
+ }
3739
+ this.acknowledgeInboundMail(mail);
3740
+ return;
3741
+ }
3742
+
3653
3743
  if (this.manuallyClosing) {
3654
3744
  if (libvexDebugDmEnabled()) {
3655
3745
  debugLibvexDm("readMail: skip (manually closing)", {
@@ -4056,40 +4146,110 @@ export class Client {
4056
4146
  return;
4057
4147
  }
4058
4148
 
4149
+ const originalSession = cloneSessionCrypto(session);
4059
4150
  const firstInboundFromSubsequent = !session.DHr;
4151
+ let candidateSession = cloneSessionCrypto(session);
4060
4152
  if (firstInboundFromSubsequent) {
4061
- session.DHr = ratchetHeader.dhPub;
4062
- // First inbound after X3DH initial mail has no prior DH ratchet.
4063
- // If this side has no receiving chain yet (initiator path),
4064
- // derive the bootstrap receive chain to match peer's first
4065
- // bootstrap send chain.
4066
- if (!session.CKr) {
4067
- session.CKr = deriveBootstrapSendChain(
4068
- session.RK,
4153
+ candidateSession.DHr = ratchetHeader.dhPub;
4154
+ // First inbound after X3DH initial mail can be either:
4155
+ // - peer's bootstrap send chain if they replied before seeing
4156
+ // one of our subsequent messages; or
4157
+ // - a real DH ratchet if they already received one from us.
4158
+ // Try bootstrap first for backwards compatibility, then fall
4159
+ // back to the DH-ratchet interpretation if HMAC disagrees.
4160
+ if (!candidateSession.CKr) {
4161
+ candidateSession.CKr = deriveBootstrapSendChain(
4162
+ candidateSession.RK,
4069
4163
  );
4070
4164
  }
4071
4165
  } else if (
4072
- hasRemoteDhChanged(session.DHr, ratchetHeader.dhPub)
4166
+ hasRemoteDhChanged(
4167
+ candidateSession.DHr,
4168
+ ratchetHeader.dhPub,
4169
+ )
4073
4170
  ) {
4074
4171
  await ratchetStepReceive(
4075
- session,
4172
+ candidateSession,
4076
4173
  ratchetHeader.dhPub,
4077
4174
  ratchetHeader.pn,
4078
4175
  );
4079
4176
  }
4080
4177
 
4081
- const messageKey = takeReceiveMessageKey(
4082
- session,
4178
+ let messageKey = takeReceiveMessageKey(
4179
+ candidateSession,
4083
4180
  ratchetHeader.dhPub,
4084
4181
  ratchetHeader.n,
4085
4182
  );
4086
- const HMAC = xHMAC(mail, messageKey);
4183
+ let HMAC = xHMAC(mail, messageKey);
4184
+
4185
+ if (
4186
+ !XUtils.bytesEqual(HMAC, header) &&
4187
+ firstInboundFromSubsequent &&
4188
+ !originalSession.CKr
4189
+ ) {
4190
+ const ratchetedCandidate =
4191
+ cloneSessionCrypto(originalSession);
4192
+ await ratchetStepReceive(
4193
+ ratchetedCandidate,
4194
+ ratchetHeader.dhPub,
4195
+ ratchetHeader.pn,
4196
+ );
4197
+ const ratchetedMessageKey = takeReceiveMessageKey(
4198
+ ratchetedCandidate,
4199
+ ratchetHeader.dhPub,
4200
+ ratchetHeader.n,
4201
+ );
4202
+ const ratchetedHMAC = xHMAC(
4203
+ mail,
4204
+ ratchetedMessageKey,
4205
+ );
4206
+ if (XUtils.bytesEqual(ratchetedHMAC, header)) {
4207
+ if (libvexDebugDmEnabled()) {
4208
+ debugLibvexDm(
4209
+ "readMail subsequent: first inbound used DH-ratchet fallback",
4210
+ {
4211
+ mailID: mail.mailID,
4212
+ thisDevice:
4213
+ this.getDevice().deviceID,
4214
+ },
4215
+ );
4216
+ }
4217
+ candidateSession = ratchetedCandidate;
4218
+ messageKey = ratchetedMessageKey;
4219
+ HMAC = ratchetedHMAC;
4220
+ }
4221
+ }
4087
4222
 
4088
4223
  if (!XUtils.bytesEqual(HMAC, header)) {
4224
+ const failureCount =
4225
+ this.registerDecryptFailure(mail);
4226
+ if (libvexDebugDmEnabled()) {
4227
+ debugLibvexDm(
4228
+ "readMail subsequent: abort (HMAC mismatch)",
4229
+ {
4230
+ attempts: failureCount,
4231
+ mailID: mail.mailID,
4232
+ sender: mail.sender,
4233
+ thisDevice: this.getDevice().deviceID,
4234
+ },
4235
+ );
4236
+ }
4089
4237
  healSession();
4238
+ if (failureCount === 1) {
4239
+ this.emitter.emit("retryRequest", {
4240
+ mailID: mail.mailID,
4241
+ source: "decrypt_failure",
4242
+ });
4243
+ }
4244
+ this.acknowledgeRepeatedDecryptFailure(
4245
+ mail,
4246
+ failureCount,
4247
+ );
4090
4248
  return;
4091
4249
  }
4092
4250
 
4251
+ session = candidateSession;
4252
+
4093
4253
  const decrypted = await xSecretboxOpenAsync(
4094
4254
  new Uint8Array(mail.cipher),
4095
4255
  new Uint8Array(mail.nonce),
@@ -4182,11 +4342,19 @@ export class Client {
4182
4342
  ] = session;
4183
4343
  this.acknowledgeInboundMail(mail);
4184
4344
  } else {
4345
+ const failureCount =
4346
+ this.registerDecryptFailure(mail);
4185
4347
  healSession();
4186
- this.emitter.emit("retryRequest", {
4187
- mailID: mail.mailID,
4188
- source: "decrypt_failure",
4189
- });
4348
+ if (failureCount === 1) {
4349
+ this.emitter.emit("retryRequest", {
4350
+ mailID: mail.mailID,
4351
+ source: "decrypt_failure",
4352
+ });
4353
+ }
4354
+ this.acknowledgeRepeatedDecryptFailure(
4355
+ mail,
4356
+ failureCount,
4357
+ );
4190
4358
  }
4191
4359
  break;
4192
4360
  }
@@ -4206,6 +4374,12 @@ export class Client {
4206
4374
  return decodeAxios(PermissionCodec, res.data);
4207
4375
  }
4208
4376
 
4377
+ private registerDecryptFailure(mail: MailWS): number {
4378
+ const count = (this.decryptFailureCounts.get(mail.mailID) ?? 0) + 1;
4379
+ this.decryptFailureCounts.set(mail.mailID, count);
4380
+ return count;
4381
+ }
4382
+
4209
4383
  private async registerDevice(): Promise<DeviceRegistrationResult | null> {
4210
4384
  while (!this.xKeyRing) {
4211
4385
  await sleep(100);
@@ -4381,10 +4555,12 @@ export class Client {
4381
4555
  if (newDevice && "deviceID" in newDevice) {
4382
4556
  device = newDevice;
4383
4557
  } else if (newDevice && "status" in newDevice) {
4384
- throw new Error(
4385
- "Device registration requires approval from an existing device. requestID=" +
4386
- newDevice.requestID,
4387
- );
4558
+ throw new DeviceApprovalRequiredError({
4559
+ challenge: newDevice.challenge,
4560
+ expiresAt: newDevice.expiresAt,
4561
+ requestID: newDevice.requestID,
4562
+ userID: newDevice.userID ?? null,
4563
+ });
4388
4564
  } else {
4389
4565
  throw new Error("Error registering device.");
4390
4566
  }
@@ -4488,17 +4664,32 @@ export class Client {
4488
4664
  );
4489
4665
  const msgBytes = XUtils.decodeUTF8(payload);
4490
4666
  const myUserID = this.getUser().userID;
4491
- // Fan-out only to *other* server members. The current account's other
4492
- // devices receive the same group mail via `forward()` on the outgoing
4493
- // `message` event (see `onInternalMessage`). Including our own devices
4494
- // here races X3DH/ephemeral state and often fails silently — which
4495
- // matched reports of flaky early group messages and missing delivery
4496
- // while DMs (which never self-target) behaved better.
4497
4667
  const peerUserIDs = [...new Set(userList.map((u) => u.userID))].filter(
4498
4668
  (id) => id !== myUserID,
4499
4669
  );
4670
+ const targetDevices = new Map<string, Device>();
4500
4671
 
4501
- if (peerUserIDs.length === 0) {
4672
+ if (peerUserIDs.length > 0) {
4673
+ const peerDevices = await this.getMultiUserDeviceList(peerUserIDs);
4674
+ if (peerDevices.length === 0) {
4675
+ throw new Error(
4676
+ "No devices registered for other channel members — cannot send group message.",
4677
+ );
4678
+ }
4679
+ for (const device of peerDevices) {
4680
+ targetDevices.set(device.deviceID, device);
4681
+ }
4682
+ }
4683
+
4684
+ const ownDevices = await this.fetchUserDeviceListWithBackoff(
4685
+ myUserID,
4686
+ "own",
4687
+ );
4688
+ for (const device of ownDevices) {
4689
+ targetDevices.set(device.deviceID, device);
4690
+ }
4691
+
4692
+ if (targetDevices.size === 0) {
4502
4693
  const dev = this.getDevice();
4503
4694
  const nonce = xMakeNonce();
4504
4695
  this.emitter.emit("message", {
@@ -4518,21 +4709,17 @@ export class Client {
4518
4709
  return;
4519
4710
  }
4520
4711
 
4521
- const devices = await this.getMultiUserDeviceList(peerUserIDs);
4522
- if (devices.length === 0) {
4523
- throw new Error(
4524
- "No devices registered for other channel members — cannot send group message.",
4525
- );
4526
- }
4527
-
4528
- const stableDevices = [...devices].sort((a, b) =>
4712
+ const stableDevices = [...targetDevices.values()].sort((a, b) =>
4529
4713
  a.deviceID.localeCompare(b.deviceID, "en"),
4530
4714
  );
4531
4715
 
4532
4716
  let failCount = 0;
4533
4717
  let lastErr: unknown;
4534
4718
  for (const device of stableDevices) {
4535
- const ownerRecord = this.userRecords[device.owner];
4719
+ const ownerRecord =
4720
+ device.owner === myUserID
4721
+ ? this.getUser()
4722
+ : this.userRecords[device.owner];
4536
4723
  if (!ownerRecord) {
4537
4724
  failCount += 1;
4538
4725
  continue;
package/src/Storage.ts CHANGED
@@ -64,6 +64,8 @@ export interface Storage extends EventEmitter {
64
64
  getSessionByPublicKey: (
65
65
  publicKey: Uint8Array,
66
66
  ) => Promise<null | SessionCrypto>;
67
+ /** Returns whether a message with this `mailID` already exists locally. */
68
+ hasMessage: (mailID: string) => Promise<boolean>;
67
69
  /**
68
70
  * Performs storage initialization (schema creation, migrations, warmup, etc.).
69
71
  *
@@ -155,6 +155,10 @@ export class MemoryStorage extends EventEmitter implements Storage {
155
155
  return Promise.resolve(this.sqlToCrypto(s));
156
156
  }
157
157
 
158
+ hasMessage(mailID: string): Promise<boolean> {
159
+ return Promise.resolve(this.messages.some((m) => m.mailID === mailID));
160
+ }
161
+
158
162
  init(): Promise<void> {
159
163
  this.ready = true;
160
164
  this.emit("ready");
@@ -215,6 +219,9 @@ export class MemoryStorage extends EventEmitter implements Storage {
215
219
  }
216
220
 
217
221
  async saveMessage(message: Message): Promise<void> {
222
+ if (this.messages.some((m) => m.mailID === message.mailID)) {
223
+ return;
224
+ }
218
225
  const copy = { ...message };
219
226
  const fips = getCryptoProfile() === "fips";
220
227
  const ct = fips
@@ -225,8 +225,6 @@ export class SqliteStorage extends EventEmitter implements Storage {
225
225
  };
226
226
  }
227
227
 
228
- // ── Sessions ─────────────────────────────────────────────────────────────
229
-
230
228
  async getPreKeys(): Promise<null | PreKeysCrypto> {
231
229
  await this.untilReady();
232
230
  if (this.closing) {
@@ -250,6 +248,8 @@ export class SqliteStorage extends EventEmitter implements Storage {
250
248
  };
251
249
  }
252
250
 
251
+ // ── Sessions ─────────────────────────────────────────────────────────────
252
+
253
253
  async getSessionByDeviceID(
254
254
  deviceID: string,
255
255
  ): Promise<null | SessionCrypto> {
@@ -297,6 +297,19 @@ export class SqliteStorage extends EventEmitter implements Storage {
297
297
  return this.sqlToCrypto(await this.sessionRowToSQLAsync(sessionRow));
298
298
  }
299
299
 
300
+ async hasMessage(mailID: string): Promise<boolean> {
301
+ await this.untilReady();
302
+ if (this.closing) {
303
+ return false;
304
+ }
305
+ const row = await this.db
306
+ .selectFrom("messages")
307
+ .select("mailID")
308
+ .where("mailID", "=", mailID)
309
+ .executeTakeFirst();
310
+ return row !== undefined;
311
+ }
312
+
300
313
  async init(): Promise<void> {
301
314
  if (this.ready || this.closing) {
302
315
  return;
@@ -358,6 +371,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
358
371
  .execute();
359
372
  await this.ensureSessionRatchetColumns();
360
373
  await this.ensureRetentionHintColumn();
374
+ await this.ensureMessageMailIdIndex();
361
375
 
362
376
  await this.db.schema
363
377
  .createTable("preKeys")
@@ -507,6 +521,18 @@ export class SqliteStorage extends EventEmitter implements Storage {
507
521
  if (this.isClosingNow()) {
508
522
  return;
509
523
  }
524
+ await this.untilReady();
525
+
526
+ // Fan-out to multiple devices reuses one `mailID` but each encrypt path
527
+ // uses a fresh nonce (table PK). Keep a single local row per logical mail.
528
+ const dupe = await this.db
529
+ .selectFrom("messages")
530
+ .select("nonce")
531
+ .where("mailID", "=", message.mailID)
532
+ .executeTakeFirst();
533
+ if (dupe !== undefined) {
534
+ return;
535
+ }
510
536
 
511
537
  // Encrypt plaintext with at-rest key before saving to disk
512
538
  const fips = getCryptoProfile() === "fips";
@@ -747,6 +773,19 @@ export class SqliteStorage extends EventEmitter implements Storage {
747
773
  };
748
774
  }
749
775
 
776
+ /** Speeds up mailID existence checks for saveMessage deduplication. */
777
+ private async ensureMessageMailIdIndex(): Promise<void> {
778
+ try {
779
+ await sql
780
+ .raw(
781
+ "CREATE INDEX IF NOT EXISTS messages_mailID_idx ON messages(mailID)",
782
+ )
783
+ .execute(this.db);
784
+ } catch {
785
+ // Extremely defensive — `messages` always exists at this point.
786
+ }
787
+ }
788
+
750
789
  private async ensureRetentionHintColumn(): Promise<void> {
751
790
  try {
752
791
  await sql