@vex-chat/libvex 6.2.2 → 6.3.0

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
@@ -78,6 +78,11 @@ import { EventEmitter } from "eventemitter3";
78
78
  import * as uuid from "uuid";
79
79
  import { z } from "zod/v4";
80
80
 
81
+ import {
82
+ clampLocalMessageRetentionDays,
83
+ formatVexRetentionEnvelope,
84
+ stripVexRetentionEnvelope,
85
+ } from "./retention.js";
81
86
  import {
82
87
  WebSocketAdapter,
83
88
  WebSocketNotOpenError,
@@ -363,6 +368,13 @@ export interface ClientOptions {
363
368
  host?: string;
364
369
  /** Use sqlite in-memory mode (`:memory:`) instead of a file. */
365
370
  inMemoryDb?: boolean;
371
+ /**
372
+ * Maximum age (in days) for messages kept in local storage. Values above
373
+ * 30 are clamped to 30 to match server retention. Peers may request a
374
+ * shorter window via an optional plaintext hint; this setting is still
375
+ * capped at 30 and is not enforceable against a modified client.
376
+ */
377
+ localMessageRetentionDays?: number;
366
378
  /** Whether local message history should be persisted by default storage. */
367
379
  saveHistory?: boolean;
368
380
  /** Use `http/ws` instead of `https/wss`. Intended for local/dev environments. */
@@ -552,6 +564,12 @@ export interface Message {
552
564
  readerID: string;
553
565
  /** Recipient device ID. */
554
566
  recipient: string;
567
+ /**
568
+ * Optional peer hint (1–30): cooperative senders prefix plaintext; used
569
+ * with {@link ClientOptions.localMessageRetentionDays} to pick the
570
+ * shorter local retention window. Ignored when absent.
571
+ */
572
+ retentionHintDays?: number;
555
573
  /** Sender device ID. */
556
574
  sender: string;
557
575
  /** Time the message was created/received. */
@@ -740,7 +758,11 @@ export interface Messages {
740
758
  /** Deletes local history for a user/channel. */
741
759
  delete: (userOrChannelID: string) => Promise<void>;
742
760
  /** Sends an encrypted message to all members of a channel. */
743
- group: (channelID: string, message: string) => Promise<void>;
761
+ group: (
762
+ channelID: string,
763
+ message: string,
764
+ opts?: { retentionHintDays?: number },
765
+ ) => Promise<void>;
744
766
  /** Deletes all locally stored message history. */
745
767
  purge: () => Promise<void>;
746
768
  /** Returns local direct-message history with one user. */
@@ -748,7 +770,11 @@ export interface Messages {
748
770
  /** Returns local group-message history for one channel. */
749
771
  retrieveGroup: (channelID: string) => Promise<Message[]>;
750
772
  /** Sends an encrypted direct message to one user. */
751
- send: (userID: string, message: string) => Promise<void>;
773
+ send: (
774
+ userID: string,
775
+ message: string,
776
+ opts?: { retentionHintDays?: number },
777
+ ) => Promise<void>;
752
778
  }
753
779
 
754
780
  /**
@@ -1072,7 +1098,11 @@ export class Client {
1072
1098
  * @param channelID - The channel to send a message to.
1073
1099
  * @param message - The message to send.
1074
1100
  */
1075
- group: this.sendGroupMessage.bind(this),
1101
+ group: (
1102
+ channelID: string,
1103
+ message: string,
1104
+ opts?: { retentionHintDays?: number },
1105
+ ) => this.sendGroupMessage(channelID, message, opts),
1076
1106
  purge: this.purgeHistory.bind(this),
1077
1107
  /**
1078
1108
  * Gets the message history with a specific userID.
@@ -1093,7 +1123,11 @@ export class Client {
1093
1123
  * @param userID - The user to send a message to.
1094
1124
  * @param message - The message to send.
1095
1125
  */
1096
- send: this.sendMessage.bind(this),
1126
+ send: (
1127
+ userID: string,
1128
+ message: string,
1129
+ opts?: { retentionHintDays?: number },
1130
+ ) => this.sendMessage(userID, message, opts),
1097
1131
  };
1098
1132
  /**
1099
1133
  * Server moderation helper methods.
@@ -1250,9 +1284,13 @@ export class Client {
1250
1284
  private readonly httpAbortController = new AbortController();
1251
1285
  private readonly idKeys: KeyPair | null;
1252
1286
  private isAlive: boolean = true;
1253
- private readonly mailInterval?: NodeJS.Timeout;
1287
+ private localMessageRetentionDays: number;
1254
1288
 
1289
+ private localRetentionPurgeTimer: null | ReturnType<typeof setInterval> =
1290
+ null;
1291
+ private readonly mailInterval?: NodeJS.Timeout;
1255
1292
  private manuallyClosing: boolean = false;
1293
+
1256
1294
  /**
1257
1295
  * Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
1258
1296
  * Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
@@ -1266,20 +1304,21 @@ export class Client {
1266
1304
  and finally falls back to username. */
1267
1305
  /** Negative cache for user lookups that returned 404. TTL = 30 minutes. */
1268
1306
  private readonly notFoundUsers = new Map<string, number>();
1269
-
1270
1307
  private readonly options?: ClientOptions | undefined;
1271
1308
 
1272
1309
  private pingInterval: null | ReturnType<typeof setTimeout> = null;
1310
+
1273
1311
  /**
1274
1312
  * Bumped when the WebSocket is torn down and re-opened so the previous
1275
1313
  * `postAuth` loop exits instead of overlapping a new one.
1276
1314
  */
1277
1315
  private postAuthVersion = 0;
1278
-
1279
1316
  private readonly prefixes:
1280
1317
  | { HTTP: "http://"; WS: "ws://" }
1281
1318
  | { HTTP: "https://"; WS: "wss://" };
1319
+
1282
1320
  private reading: boolean = false;
1321
+ private retentionPurgeDebounce: null | ReturnType<typeof setTimeout> = null;
1283
1322
  private readonly seenMailIDs: Set<string> = new Set();
1284
1323
  private sessionRecords: Record<string, SessionCrypto> = {};
1285
1324
 
@@ -1303,6 +1342,9 @@ export class Client {
1303
1342
  storage?: Storage,
1304
1343
  ) {
1305
1344
  this.options = options;
1345
+ this.localMessageRetentionDays = clampLocalMessageRetentionDays(
1346
+ options?.localMessageRetentionDays,
1347
+ );
1306
1348
  this.cryptoProfile = material.cryptoProfile;
1307
1349
  this.signKeys = material.signKeys;
1308
1350
  this.idKeys = material.idKeys;
@@ -1600,6 +1642,14 @@ export class Client {
1600
1642
  if (this.mailInterval) {
1601
1643
  clearInterval(this.mailInterval);
1602
1644
  }
1645
+ if (this.localRetentionPurgeTimer) {
1646
+ clearInterval(this.localRetentionPurgeTimer);
1647
+ this.localRetentionPurgeTimer = null;
1648
+ }
1649
+ if (this.retentionPurgeDebounce) {
1650
+ clearTimeout(this.retentionPurgeDebounce);
1651
+ this.retentionPurgeDebounce = null;
1652
+ }
1603
1653
  delete this.xKeyRing;
1604
1654
 
1605
1655
  if (!muteEvent) {
@@ -1679,6 +1729,11 @@ export class Client {
1679
1729
  };
1680
1730
  }
1681
1731
 
1732
+ /** Current local retention cap in days (always 1–30). */
1733
+ public getLocalMessageRetentionDays(): number {
1734
+ return this.localMessageRetentionDays;
1735
+ }
1736
+
1682
1737
  /**
1683
1738
  * Authenticates with username/password and stores the Bearer auth token.
1684
1739
  *
@@ -2019,6 +2074,15 @@ export class Client {
2019
2074
  return this;
2020
2075
  }
2021
2076
 
2077
+ /**
2078
+ * Updates the local retention cap (1–30 days) and prunes immediately.
2079
+ * Does not affect server-side storage.
2080
+ */
2081
+ public setLocalMessageRetentionDays(days: number): void {
2082
+ this.localMessageRetentionDays = clampLocalMessageRetentionDays(days);
2083
+ void this.runLocalRetentionPurge();
2084
+ }
2085
+
2022
2086
  /**
2023
2087
  * Triggers an immediate inbox sync by fetching `/mail` once.
2024
2088
  * Useful on mobile foreground resume where background work may pause.
@@ -2060,6 +2124,11 @@ export class Client {
2060
2124
  return whoami;
2061
2125
  }
2062
2126
 
2127
+ private acknowledgeInboundMail(mail: MailWS): void {
2128
+ this.seenMailIDs.add(mail.mailID);
2129
+ this.sendReceipt(new Uint8Array(mail.nonce));
2130
+ }
2131
+
2063
2132
  private async approveDeviceRequest(requestID: string): Promise<Device> {
2064
2133
  const req = await this.getDeviceRegistrationRequest(requestID);
2065
2134
  if (!req) {
@@ -2496,7 +2565,6 @@ export class Client {
2496
2565
  private async deleteServer(serverID: string): Promise<void> {
2497
2566
  await this.http.delete(this.getHost() + "/server/" + serverID);
2498
2567
  }
2499
-
2500
2568
  private deviceListFailureDetail(err: unknown): string {
2501
2569
  if (!isAxiosError(err)) {
2502
2570
  return "";
@@ -2510,7 +2578,6 @@ export class Client {
2510
2578
  }
2511
2579
  return "";
2512
2580
  }
2513
-
2514
2581
  /**
2515
2582
  * Gets a list of permissions for a server.
2516
2583
  *
@@ -2526,6 +2593,7 @@ export class Client {
2526
2593
  );
2527
2594
  return decodeAxios(PermissionArrayCodec, res.data);
2528
2595
  }
2596
+
2529
2597
  private async fetchUser(
2530
2598
  userIdentifier: string,
2531
2599
  ): Promise<[null | User, AxiosError | null]> {
@@ -3049,6 +3117,8 @@ export class Client {
3049
3117
  }
3050
3118
  }
3051
3119
 
3120
+ // ── Passkeys ────────────────────────────────────────────────────────
3121
+
3052
3122
  /**
3053
3123
  * Initializes the keyring. This must be called before anything else.
3054
3124
  */
@@ -3068,9 +3138,16 @@ export class Client {
3068
3138
 
3069
3139
  await this.populateKeyRing();
3070
3140
  this.emitter.on("message", this.onInternalMessage);
3141
+ void this.runLocalRetentionPurge();
3142
+ this.localRetentionPurgeTimer = setInterval(
3143
+ () => void this.runLocalRetentionPurge(),
3144
+ 6 * 60 * 60 * 1000,
3145
+ );
3071
3146
  this.emitter.emit("ready");
3072
3147
  }
3073
3148
 
3149
+ // ── Passkeys ────────────────────────────────────────────────────────
3150
+
3074
3151
  private initSocket() {
3075
3152
  try {
3076
3153
  if (!this.token) {
@@ -3169,8 +3246,6 @@ export class Client {
3169
3246
  }
3170
3247
  }
3171
3248
 
3172
- // ── Passkeys ────────────────────────────────────────────────────────
3173
-
3174
3249
  /**
3175
3250
  * Fresh read of the `manuallyClosing` flag for async loops — direct property checks
3176
3251
  * after `await` are flagged as always-false by control-flow analysis even though
@@ -3279,6 +3354,7 @@ export class Client {
3279
3354
  return;
3280
3355
  }
3281
3356
  void this.database.saveMessage(message);
3357
+ this.scheduleRetentionPurge();
3282
3358
  };
3283
3359
 
3284
3360
  private async passkeyApproveDeviceRequest(
@@ -3503,7 +3579,6 @@ export class Client {
3503
3579
  }
3504
3580
  return;
3505
3581
  }
3506
- this.seenMailIDs.add(mail.mailID);
3507
3582
 
3508
3583
  if (this.manuallyClosing) {
3509
3584
  if (libvexDebugDmEnabled()) {
@@ -3514,7 +3589,6 @@ export class Client {
3514
3589
  return;
3515
3590
  }
3516
3591
 
3517
- this.sendReceipt(new Uint8Array(mail.nonce));
3518
3592
  let timeout = 1;
3519
3593
  while (this.reading) {
3520
3594
  await sleep(timeout);
@@ -3709,13 +3783,39 @@ export class Client {
3709
3783
  if (!mail.forward) {
3710
3784
  plaintext = XUtils.encodeUTF8(unsealed);
3711
3785
  }
3786
+ let incomingPlain = plaintext;
3787
+ let hintFromEnvelope: number | undefined;
3788
+ if (!mail.forward && plaintext.length > 0) {
3789
+ const stripped =
3790
+ stripVexRetentionEnvelope(plaintext);
3791
+ incomingPlain = stripped.body;
3792
+ hintFromEnvelope = stripped.retentionHintDays;
3793
+ }
3712
3794
 
3713
3795
  // emit the message
3714
3796
  const fwdMsg1 = mail.forward
3715
3797
  ? messageSchema.parse(msgpack.decode(unsealed))
3716
3798
  : null;
3717
3799
  const message: Message = fwdMsg1
3718
- ? { ...fwdMsg1, forward: true }
3800
+ ? (() => {
3801
+ const stripped =
3802
+ stripVexRetentionEnvelope(
3803
+ fwdMsg1.message,
3804
+ );
3805
+ const base: Message = {
3806
+ ...fwdMsg1,
3807
+ forward: true,
3808
+ message: stripped.body,
3809
+ };
3810
+ return stripped.retentionHintDays !==
3811
+ undefined
3812
+ ? {
3813
+ ...base,
3814
+ retentionHintDays:
3815
+ stripped.retentionHintDays,
3816
+ }
3817
+ : base;
3818
+ })()
3719
3819
  : {
3720
3820
  authorID: mail.authorID,
3721
3821
  decrypted: true,
@@ -3725,12 +3825,18 @@ export class Client {
3725
3825
  ? uuid.stringify(mail.group)
3726
3826
  : null,
3727
3827
  mailID: mail.mailID,
3728
- message: plaintext,
3828
+ message: incomingPlain,
3729
3829
  nonce: XUtils.encodeHex(
3730
3830
  new Uint8Array(mail.nonce),
3731
3831
  ),
3732
3832
  readerID: mail.readerID,
3733
3833
  recipient: mail.recipient,
3834
+ ...(hintFromEnvelope !== undefined
3835
+ ? {
3836
+ retentionHintDays:
3837
+ hintFromEnvelope,
3838
+ }
3839
+ : {}),
3734
3840
  sender: mail.sender,
3735
3841
  timestamp: timestamp,
3736
3842
  };
@@ -3816,6 +3922,7 @@ export class Client {
3816
3922
  this.emitter.emit("session", newSession, user);
3817
3923
  } else {
3818
3924
  }
3925
+ this.acknowledgeInboundMail(mail);
3819
3926
  } else {
3820
3927
  if (libvexDebugDmEnabled()) {
3821
3928
  debugLibvexDm(
@@ -3896,11 +4003,35 @@ export class Client {
3896
4003
  const fwdMsg2 = mail.forward
3897
4004
  ? messageSchema.parse(msgpack.decode(decrypted))
3898
4005
  : null;
4006
+ const rawIncoming = XUtils.encodeUTF8(decrypted);
4007
+ let bodyIncoming = rawIncoming;
4008
+ let hintIncoming: number | undefined;
4009
+ if (!mail.forward) {
4010
+ const stripped =
4011
+ stripVexRetentionEnvelope(rawIncoming);
4012
+ bodyIncoming = stripped.body;
4013
+ hintIncoming = stripped.retentionHintDays;
4014
+ }
3899
4015
  const message: Message = fwdMsg2
3900
- ? {
3901
- ...fwdMsg2,
3902
- forward: true,
3903
- }
4016
+ ? (() => {
4017
+ const stripped =
4018
+ stripVexRetentionEnvelope(
4019
+ fwdMsg2.message,
4020
+ );
4021
+ const base: Message = {
4022
+ ...fwdMsg2,
4023
+ forward: true,
4024
+ message: stripped.body,
4025
+ };
4026
+ return stripped.retentionHintDays !==
4027
+ undefined
4028
+ ? {
4029
+ ...base,
4030
+ retentionHintDays:
4031
+ stripped.retentionHintDays,
4032
+ }
4033
+ : base;
4034
+ })()
3904
4035
  : {
3905
4036
  authorID: mail.authorID,
3906
4037
  decrypted: true,
@@ -3910,12 +4041,15 @@ export class Client {
3910
4041
  ? uuid.stringify(mail.group)
3911
4042
  : null,
3912
4043
  mailID: mail.mailID,
3913
- message: XUtils.encodeUTF8(decrypted),
4044
+ message: bodyIncoming,
3914
4045
  nonce: XUtils.encodeHex(
3915
4046
  new Uint8Array(mail.nonce),
3916
4047
  ),
3917
4048
  readerID: mail.readerID,
3918
4049
  recipient: mail.recipient,
4050
+ ...(hintIncoming !== undefined
4051
+ ? { retentionHintDays: hintIncoming }
4052
+ : {}),
3919
4053
  sender: mail.sender,
3920
4054
  timestamp: timestamp,
3921
4055
  };
@@ -3949,6 +4083,7 @@ export class Client {
3949
4083
  this.sessionRecords[
3950
4084
  XUtils.encodeHex(session.publicKey)
3951
4085
  ] = session;
4086
+ this.acknowledgeInboundMail(mail);
3952
4087
  } else {
3953
4088
  void healSession();
3954
4089
  this.emitter.emit("retryRequest", {
@@ -4163,6 +4298,19 @@ export class Client {
4163
4298
  return device;
4164
4299
  }
4165
4300
 
4301
+ private async runLocalRetentionPurge(): Promise<void> {
4302
+ if (this.isManualCloseInFlight()) {
4303
+ return;
4304
+ }
4305
+ try {
4306
+ await this.database.pruneExpiredLocalMessages(
4307
+ this.localMessageRetentionDays,
4308
+ );
4309
+ } catch {
4310
+ /* best-effort */
4311
+ }
4312
+ }
4313
+
4166
4314
  /**
4167
4315
  * `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
4168
4316
  * active profile. When several {@link Client} instances use different
@@ -4184,6 +4332,16 @@ export class Client {
4184
4332
  }
4185
4333
  }
4186
4334
 
4335
+ private scheduleRetentionPurge(): void {
4336
+ if (this.retentionPurgeDebounce) {
4337
+ clearTimeout(this.retentionPurgeDebounce);
4338
+ }
4339
+ this.retentionPurgeDebounce = setTimeout(() => {
4340
+ this.retentionPurgeDebounce = null;
4341
+ void this.runLocalRetentionPurge();
4342
+ }, 3000);
4343
+ }
4344
+
4187
4345
  /* header is 32 bytes and is either empty
4188
4346
  or contains an HMAC of the message with
4189
4347
  a derived SK */
@@ -4219,6 +4377,7 @@ export class Client {
4219
4377
  private async sendGroupMessage(
4220
4378
  channelID: string,
4221
4379
  message: string,
4380
+ opts?: { retentionHintDays?: number },
4222
4381
  ): Promise<void> {
4223
4382
  const userList = await this.getUserList(channelID);
4224
4383
  for (const user of userList) {
@@ -4226,28 +4385,89 @@ export class Client {
4226
4385
  }
4227
4386
 
4228
4387
  const mailID = uuid.v4();
4388
+ const payload = formatVexRetentionEnvelope(
4389
+ message,
4390
+ opts?.retentionHintDays,
4391
+ );
4392
+ const msgBytes = XUtils.decodeUTF8(payload);
4393
+ const myUserID = this.getUser().userID;
4394
+ // Fan-out only to *other* server members. The current account's other
4395
+ // devices receive the same group mail via `forward()` on the outgoing
4396
+ // `message` event (see `onInternalMessage`). Including our own devices
4397
+ // here races X3DH/ephemeral state and often fails silently — which
4398
+ // matched reports of flaky early group messages and missing delivery
4399
+ // while DMs (which never self-target) behaved better.
4400
+ const peerUserIDs = [...new Set(userList.map((u) => u.userID))].filter(
4401
+ (id) => id !== myUserID,
4402
+ );
4229
4403
 
4230
- const userIDs = [...new Set(userList.map((user) => user.userID))];
4231
- const devices = await this.getMultiUserDeviceList(userIDs);
4404
+ if (peerUserIDs.length === 0) {
4405
+ const dev = this.getDevice();
4406
+ const nonce = xMakeNonce();
4407
+ this.emitter.emit("message", {
4408
+ authorID: myUserID,
4409
+ decrypted: true,
4410
+ direction: "outgoing",
4411
+ forward: false,
4412
+ group: channelID,
4413
+ mailID,
4414
+ message,
4415
+ nonce: XUtils.encodeHex(nonce),
4416
+ readerID: myUserID,
4417
+ recipient: dev.deviceID,
4418
+ sender: dev.deviceID,
4419
+ timestamp: new Date().toISOString(),
4420
+ });
4421
+ return;
4422
+ }
4232
4423
 
4233
- for (const device of devices) {
4424
+ const devices = await this.getMultiUserDeviceList(peerUserIDs);
4425
+ if (devices.length === 0) {
4426
+ throw new Error(
4427
+ "No devices registered for other channel members — cannot send group message.",
4428
+ );
4429
+ }
4430
+
4431
+ const stableDevices = [...devices].sort((a, b) =>
4432
+ a.deviceID.localeCompare(b.deviceID, "en"),
4433
+ );
4434
+
4435
+ let failCount = 0;
4436
+ let lastErr: unknown;
4437
+ for (const device of stableDevices) {
4234
4438
  const ownerRecord = this.userRecords[device.owner];
4235
4439
  if (!ownerRecord) {
4440
+ failCount += 1;
4236
4441
  continue;
4237
4442
  }
4238
4443
  try {
4239
4444
  await this.sendMail(
4240
4445
  device,
4241
4446
  ownerRecord,
4242
- XUtils.decodeUTF8(message),
4447
+ msgBytes,
4243
4448
  uuidToUint8(channelID),
4244
4449
  mailID,
4245
4450
  false,
4246
4451
  );
4247
- } catch {
4248
- /* best-effort; each device needs its own X3DH handshake (sequential) */
4452
+ } catch (e) {
4453
+ lastErr = e;
4454
+ failCount += 1;
4249
4455
  }
4250
4456
  }
4457
+
4458
+ if (failCount === stableDevices.length) {
4459
+ throw lastErr instanceof Error
4460
+ ? lastErr
4461
+ : new Error(String(lastErr));
4462
+ }
4463
+ if (failCount > 0) {
4464
+ const partial = new Error(
4465
+ `Group message failed to reach ${String(failCount)} of ` +
4466
+ `${String(stableDevices.length)} peer device(s).`,
4467
+ );
4468
+ partial.cause = lastErr;
4469
+ throw partial;
4470
+ }
4251
4471
  }
4252
4472
 
4253
4473
  /* Sends encrypted mail to a user. */
@@ -4341,8 +4561,25 @@ export class Client {
4341
4561
  const fwdOut = forward
4342
4562
  ? messageSchema.parse(msgpack.decode(msg))
4343
4563
  : null;
4564
+ const rawUtf8 = XUtils.encodeUTF8(msg);
4565
+ const strippedOut = stripVexRetentionEnvelope(rawUtf8);
4344
4566
  const outMsg: Message = fwdOut
4345
- ? { ...fwdOut, forward: true }
4567
+ ? (() => {
4568
+ const stripped = stripVexRetentionEnvelope(
4569
+ fwdOut.message,
4570
+ );
4571
+ const base: Message = {
4572
+ ...fwdOut,
4573
+ forward: true,
4574
+ message: stripped.body,
4575
+ };
4576
+ return stripped.retentionHintDays !== undefined
4577
+ ? {
4578
+ ...base,
4579
+ retentionHintDays: stripped.retentionHintDays,
4580
+ }
4581
+ : base;
4582
+ })()
4346
4583
  : {
4347
4584
  authorID: mail.authorID,
4348
4585
  decrypted: true,
@@ -4350,10 +4587,16 @@ export class Client {
4350
4587
  forward: mail.forward,
4351
4588
  group: mail.group ? uuid.stringify(mail.group) : null,
4352
4589
  mailID: mail.mailID,
4353
- message: XUtils.encodeUTF8(msg),
4590
+ message: strippedOut.body,
4354
4591
  nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
4355
4592
  readerID: mail.readerID,
4356
4593
  recipient: mail.recipient,
4594
+ ...(strippedOut.retentionHintDays !== undefined
4595
+ ? {
4596
+ retentionHintDays:
4597
+ strippedOut.retentionHintDays,
4598
+ }
4599
+ : {}),
4357
4600
  sender: mail.sender,
4358
4601
  timestamp: new Date().toISOString(),
4359
4602
  };
@@ -4418,7 +4661,15 @@ export class Client {
4418
4661
  }
4419
4662
  }
4420
4663
 
4421
- private async sendMessage(userID: string, message: string): Promise<void> {
4664
+ private async sendMessage(
4665
+ userID: string,
4666
+ message: string,
4667
+ opts?: { retentionHintDays?: number },
4668
+ ): Promise<void> {
4669
+ const payload = formatVexRetentionEnvelope(
4670
+ message,
4671
+ opts?.retentionHintDays,
4672
+ );
4422
4673
  try {
4423
4674
  const [userEntry, err] = await this.fetchUser(userID);
4424
4675
  if (err) {
@@ -4491,7 +4742,7 @@ export class Client {
4491
4742
  await this.sendMail(
4492
4743
  device,
4493
4744
  userEntry,
4494
- XUtils.decodeUTF8(message),
4745
+ XUtils.decodeUTF8(payload),
4495
4746
  null,
4496
4747
  messageMailID,
4497
4748
  false,
package/src/Storage.ts CHANGED
@@ -91,6 +91,13 @@ export interface Storage extends EventEmitter {
91
91
  * @event
92
92
  */
93
93
  on(event: "error", callback: (error: Error) => void): this;
94
+ /**
95
+ * Deletes local messages older than the effective per-row retention:
96
+ * `min(30 days, clientMaxRetentionDays, retentionHintDays ?? 30)`.
97
+ */
98
+ pruneExpiredLocalMessages: (
99
+ clientMaxRetentionDays: number,
100
+ ) => Promise<void>;
94
101
  /** Deletes all message history. */
95
102
  purgeHistory: () => Promise<void>;
96
103
  /** Deletes all local key/session state. */
@@ -170,6 +170,25 @@ export class MemoryStorage extends EventEmitter implements Storage {
170
170
  return Promise.resolve();
171
171
  }
172
172
 
173
+ pruneExpiredLocalMessages(clientMaxRetentionDays: number): Promise<void> {
174
+ const cap = Math.min(
175
+ 30,
176
+ Math.max(1, Math.round(clientMaxRetentionDays)),
177
+ );
178
+ const now = Date.now();
179
+ const msPerDay = 86_400_000;
180
+ this.messages = this.messages.filter((m) => {
181
+ const hintDays = m.retentionHintDays ?? 30;
182
+ const maxDays = Math.min(30, cap, hintDays);
183
+ const ts = new Date(m.timestamp).getTime();
184
+ if (!Number.isFinite(ts)) {
185
+ return true;
186
+ }
187
+ return now - ts <= maxDays * msPerDay;
188
+ });
189
+ return Promise.resolve();
190
+ }
191
+
173
192
  purgeHistory(): Promise<void> {
174
193
  this.messages = [];
175
194
  return Promise.resolve();