@vex-chat/libvex 6.8.0 → 7.0.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
@@ -422,7 +422,12 @@ export interface Devices {
422
422
  challenge: string;
423
423
  requestID: string;
424
424
  }) => Promise<void>;
425
- /** Approves a pending device registration request as the current device. */
425
+ /**
426
+ * Approves a pending device registration request as the current device.
427
+ * Servers with required passkeys expect the current bearer token to be a
428
+ * fresh passkey session while the current device token identifies the
429
+ * approving device.
430
+ */
426
431
  approveRequest: (requestID: string) => Promise<Device>;
427
432
  /** Deletes one of the account's devices (except the currently active one). */
428
433
  delete: (deviceID: string) => Promise<void>;
@@ -705,8 +710,6 @@ export interface NotificationSubscriptionInput {
705
710
  * @public
706
711
  */
707
712
  export interface Passkeys {
708
- /** Approves a pending device-enrollment request using the passkey session. */
709
- approveDeviceRequest: (requestID: string) => Promise<Device>;
710
713
  /** Begin a public passkey authentication ceremony for `username`. */
711
714
  beginAuthentication: (username: string) => Promise<{
712
715
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- WebAuthn options shape varies per simplewebauthn version
@@ -748,6 +751,12 @@ export interface Passkeys {
748
751
  list: () => Promise<Passkey[]>;
749
752
  /** List all of the account's devices using the passkey session. */
750
753
  listDevices: () => Promise<Device[]>;
754
+ /**
755
+ * Recover the account onto a pending device using the passkey
756
+ * session. The server approves the pending device and revokes all
757
+ * previously-active devices for the account.
758
+ */
759
+ recoverDeviceRequest: (requestID: string) => Promise<Device>;
751
760
  /** Reject a pending device-enrollment request using the passkey session. */
752
761
  rejectDeviceRequest: (requestID: string) => Promise<void>;
753
762
  }
@@ -866,6 +875,9 @@ const messageSchema: z.ZodType<Message> = z.object({
866
875
  });
867
876
 
868
877
  const MESSAGE_BLOB_PREFIX = "vex-message:1\n";
878
+ const MAIL_FANOUT_CONCURRENCY = 8;
879
+ const MAIL_BATCH_MAX_SIZE = 32;
880
+ const MAIL_BATCH_FLUSH_DELAY_MS = 8;
869
881
 
870
882
  interface DecodedMessagePlaintext {
871
883
  extra?: null | string | undefined;
@@ -873,6 +885,27 @@ interface DecodedMessagePlaintext {
873
885
  retentionHintDays?: number | undefined;
874
886
  }
875
887
 
888
+ interface PendingMailBatchDelivery {
889
+ header: Uint8Array;
890
+ mail: MailWS;
891
+ msg: ResourceMsg;
892
+ reject: (err: unknown) => void;
893
+ resolve: () => void;
894
+ }
895
+
896
+ const mailBatchResponseSchema = z.object({
897
+ results: z.array(
898
+ z.object({
899
+ error: z.string().optional(),
900
+ index: z.number().int().nonnegative(),
901
+ mailID: z.string().optional(),
902
+ ok: z.boolean(),
903
+ recipient: z.string().optional(),
904
+ status: z.number().int().optional(),
905
+ }),
906
+ ),
907
+ });
908
+
876
909
  function decodeMessageBlob(body: string): DecodedMessagePlaintext {
877
910
  if (!body.startsWith(MESSAGE_BLOB_PREFIX)) {
878
911
  return { message: body };
@@ -1406,16 +1439,15 @@ export class Client {
1406
1439
  * Passkey ("recovery credential") methods.
1407
1440
  *
1408
1441
  * Passkeys are an account-bound second-class credential that can
1409
- * authenticate the owning user, list devices, delete devices, and
1410
- * approve/reject pending device-enrollment requests — i.e.
1411
- * provisioning + recovery. They cannot send/decrypt mail.
1442
+ * authenticate the owning user, list devices, delete devices, recover a
1443
+ * pending device enrollment, and reject pending device-enrollment
1444
+ * requests. They cannot send/decrypt mail.
1412
1445
  *
1413
1446
  * The host app drives the WebAuthn ceremony (e.g. via
1414
1447
  * `@simplewebauthn/browser`) and hands the JSON response to
1415
1448
  * `finish*`.
1416
1449
  */
1417
1450
  public passkeys: Passkeys = {
1418
- approveDeviceRequest: this.passkeyApproveDeviceRequest.bind(this),
1419
1451
  beginAuthentication: this.beginPasskeyAuthentication.bind(this),
1420
1452
  beginRegistration: this.beginPasskeyRegistration.bind(this),
1421
1453
  delete: this.deletePasskey.bind(this),
@@ -1424,6 +1456,7 @@ export class Client {
1424
1456
  finishRegistration: this.finishPasskeyRegistration.bind(this),
1425
1457
  list: this.listPasskeys.bind(this),
1426
1458
  listDevices: this.passkeyListDevices.bind(this),
1459
+ recoverDeviceRequest: this.passkeyRecoverDeviceRequest.bind(this),
1427
1460
  rejectDeviceRequest: this.passkeyRejectDeviceRequest.bind(this),
1428
1461
  };
1429
1462
 
@@ -1557,6 +1590,9 @@ export class Client {
1557
1590
  private localMessageRetentionDays: number;
1558
1591
  private localRetentionPurgeTimer: null | ReturnType<typeof setInterval> =
1559
1592
  null;
1593
+ private mailBatchFlushTimer: null | ReturnType<typeof setTimeout> = null;
1594
+ private readonly mailBatchQueue: PendingMailBatchDelivery[] = [];
1595
+ private mailBatchUnsupported = false;
1560
1596
  private readonly mailInterval?: NodeJS.Timeout;
1561
1597
 
1562
1598
  private manuallyClosing: boolean = false;
@@ -1879,6 +1915,14 @@ export class Client {
1879
1915
  if (this.mailInterval) {
1880
1916
  clearInterval(this.mailInterval);
1881
1917
  }
1918
+ if (this.mailBatchFlushTimer) {
1919
+ clearTimeout(this.mailBatchFlushTimer);
1920
+ this.mailBatchFlushTimer = null;
1921
+ }
1922
+ const pendingMailBatch = this.mailBatchQueue.splice(0);
1923
+ for (const pending of pendingMailBatch) {
1924
+ pending.reject(new Error("Client closed before mail batch sent."));
1925
+ }
1882
1926
  if (this.localRetentionPurgeTimer) {
1883
1927
  clearInterval(this.localRetentionPurgeTimer);
1884
1928
  this.localRetentionPurgeTimer = null;
@@ -2674,7 +2718,8 @@ export class Client {
2674
2718
  // my keys
2675
2719
  const IK_A = this.xKeyRing.identityKeys.secretKey;
2676
2720
  const IK_AP = this.xKeyRing.identityKeys.publicKey;
2677
- const EK_A = this.xKeyRing.ephemeralKeys.secretKey;
2721
+ const ephemeralKeys = await xBoxKeyPairAsync();
2722
+ const EK_A = ephemeralKeys.secretKey;
2678
2723
 
2679
2724
  const fips = this.cryptoProfile === "fips";
2680
2725
  // their keys — FIPS: `signKey` in bundle is the peer P-256 ECDH identity (raw, typically 65B).
@@ -2732,13 +2777,13 @@ export class Client {
2732
2777
  const cipher = await xSecretboxAsync(message, nonce, SK);
2733
2778
 
2734
2779
  const signKeyWire = fips ? IK_AP : this.signKeys.publicKey;
2735
- const ephKeyWire = this.xKeyRing.ephemeralKeys.publicKey;
2780
+ const ephKeyWire = ephemeralKeys.publicKey;
2736
2781
 
2737
2782
  const extra = fips
2738
2783
  ? encodeFipsInitialExtraV1(signKeyWire, ephKeyWire, PK, AD, IDX)
2739
2784
  : xConcat(
2740
2785
  this.signKeys.publicKey,
2741
- this.xKeyRing.ephemeralKeys.publicKey,
2786
+ ephemeralKeys.publicKey,
2742
2787
  PK,
2743
2788
  AD,
2744
2789
  IDX,
@@ -2768,9 +2813,6 @@ export class Client {
2768
2813
  type: "resource",
2769
2814
  };
2770
2815
 
2771
- // discard the ephemeral keys
2772
- await this.newEphemeralKeys();
2773
-
2774
2816
  const ratchet = await initRatchetSession(SK, "initiator");
2775
2817
  const sessionEntry: SessionSQL = {
2776
2818
  ...ratchet,
@@ -2816,37 +2858,7 @@ export class Client {
2816
2858
  this.emitter.emit("message", emitMsg);
2817
2859
  }
2818
2860
 
2819
- // send mail and wait for response
2820
- await new Promise((res, rej) => {
2821
- const callback = (packedMsg: Uint8Array) => {
2822
- const [_header, receivedMsg] =
2823
- XUtils.unpackMessage(packedMsg);
2824
- if (receivedMsg.transmissionID === msg.transmissionID) {
2825
- this.socket.off("message", callback);
2826
- const parsed = WSMessageSchema.safeParse(receivedMsg);
2827
- if (parsed.success && parsed.data.type === "success") {
2828
- res(parsed.data.data);
2829
- } else {
2830
- rej(
2831
- new Error(
2832
- "Mail delivery failed: " +
2833
- JSON.stringify(receivedMsg),
2834
- ),
2835
- );
2836
- }
2837
- }
2838
- };
2839
- this.socket.on("message", callback);
2840
- // Forward send failures to the outer promise instead
2841
- // of leaking them as an unhandled rejection: the
2842
- // listener above can never resolve if the send didn't
2843
- // make it onto the wire, so without this the caller
2844
- // would hang for the full 30s send-loop timeout.
2845
- this.send(msg, hmac).catch((err: unknown) => {
2846
- this.socket.off("message", callback);
2847
- rej(err instanceof Error ? err : new Error(String(err)));
2848
- });
2849
- });
2861
+ await this.deliverMailResource(msg, hmac, mail);
2850
2862
  });
2851
2863
  }
2852
2864
 
@@ -2886,6 +2898,59 @@ export class Client {
2886
2898
  private async deleteServer(serverID: string): Promise<void> {
2887
2899
  await this.http.delete(this.getHost() + "/server/" + serverID);
2888
2900
  }
2901
+ private deliverMailResource(
2902
+ msg: ResourceMsg,
2903
+ header: Uint8Array,
2904
+ mail: MailWS,
2905
+ ): Promise<void> {
2906
+ if (this.mailBatchUnsupported) {
2907
+ return this.deliverMailResourceOverSocket(msg, header);
2908
+ }
2909
+ return new Promise<void>((resolve, reject) => {
2910
+ this.mailBatchQueue.push({
2911
+ header,
2912
+ mail,
2913
+ msg,
2914
+ reject,
2915
+ resolve,
2916
+ });
2917
+ if (this.mailBatchQueue.length >= MAIL_BATCH_MAX_SIZE) {
2918
+ void this.flushMailBatchQueue();
2919
+ } else {
2920
+ this.scheduleMailBatchFlush();
2921
+ }
2922
+ });
2923
+ }
2924
+ private async deliverMailResourceOverSocket(
2925
+ msg: ResourceMsg,
2926
+ header: Uint8Array,
2927
+ ): Promise<void> {
2928
+ await new Promise<void>((res, rej) => {
2929
+ const callback = (packedMsg: Uint8Array) => {
2930
+ const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
2931
+ if (receivedMsg.transmissionID === msg.transmissionID) {
2932
+ this.socket.off("message", callback);
2933
+ const parsed = WSMessageSchema.safeParse(receivedMsg);
2934
+ if (parsed.success && parsed.data.type === "success") {
2935
+ res();
2936
+ } else {
2937
+ rej(
2938
+ new Error(
2939
+ "Mail delivery failed: " +
2940
+ JSON.stringify(receivedMsg),
2941
+ ),
2942
+ );
2943
+ }
2944
+ }
2945
+ };
2946
+ this.socket.on("message", callback);
2947
+ this.send(msg, header).catch((err: unknown) => {
2948
+ this.socket.off("message", callback);
2949
+ rej(err instanceof Error ? err : new Error(String(err)));
2950
+ });
2951
+ });
2952
+ }
2953
+
2889
2954
  private deviceListFailureDetail(err: unknown): string {
2890
2955
  if (!isHttpError(err)) {
2891
2956
  return "";
@@ -2899,6 +2964,7 @@ export class Client {
2899
2964
  }
2900
2965
  return "";
2901
2966
  }
2967
+
2902
2968
  /**
2903
2969
  * Gets a list of permissions for a server.
2904
2970
  *
@@ -3053,6 +3119,84 @@ export class Client {
3053
3119
  return decodeHttpResponse(PasskeyCodec, response.data);
3054
3120
  }
3055
3121
 
3122
+ private async flushMailBatchOverSocket(
3123
+ batch: PendingMailBatchDelivery[],
3124
+ ): Promise<void> {
3125
+ await Promise.all(
3126
+ batch.map(async (item) => {
3127
+ try {
3128
+ await this.deliverMailResourceOverSocket(
3129
+ item.msg,
3130
+ item.header,
3131
+ );
3132
+ item.resolve();
3133
+ } catch (err: unknown) {
3134
+ item.reject(err);
3135
+ }
3136
+ }),
3137
+ );
3138
+ }
3139
+
3140
+ private async flushMailBatchQueue(): Promise<void> {
3141
+ if (this.mailBatchFlushTimer) {
3142
+ clearTimeout(this.mailBatchFlushTimer);
3143
+ this.mailBatchFlushTimer = null;
3144
+ }
3145
+ const batch = this.mailBatchQueue.splice(0, MAIL_BATCH_MAX_SIZE);
3146
+ if (this.mailBatchQueue.length > 0) {
3147
+ this.scheduleMailBatchFlush();
3148
+ }
3149
+ if (batch.length === 0) {
3150
+ return;
3151
+ }
3152
+ if (this.mailBatchUnsupported) {
3153
+ await this.flushMailBatchOverSocket(batch);
3154
+ return;
3155
+ }
3156
+
3157
+ try {
3158
+ const response = await this.http.post(
3159
+ this.getHost() + "/mail/batch",
3160
+ msgpack.encode({
3161
+ mails: batch.map((item) => ({
3162
+ header: item.header,
3163
+ mail: item.mail,
3164
+ })),
3165
+ }),
3166
+ { headers: { "Content-Type": "application/msgpack" } },
3167
+ );
3168
+ const decoded = mailBatchResponseSchema.parse(
3169
+ msgpack.decode(new Uint8Array(response.data)),
3170
+ );
3171
+ const resultsByIndex = new Map(
3172
+ decoded.results.map((result) => [result.index, result]),
3173
+ );
3174
+ for (const [index, item] of batch.entries()) {
3175
+ const result = resultsByIndex.get(index);
3176
+ if (result?.ok === true) {
3177
+ item.resolve();
3178
+ continue;
3179
+ }
3180
+ item.reject(
3181
+ new Error(
3182
+ "Mail delivery failed: " +
3183
+ (result?.error ??
3184
+ `missing batch result for index ${String(index)}`),
3185
+ ),
3186
+ );
3187
+ }
3188
+ } catch (err: unknown) {
3189
+ if (isHttpError(err) && err.response?.status === 404) {
3190
+ this.mailBatchUnsupported = true;
3191
+ await this.flushMailBatchOverSocket(batch);
3192
+ return;
3193
+ }
3194
+ for (const item of batch) {
3195
+ item.reject(err);
3196
+ }
3197
+ }
3198
+ }
3199
+
3056
3200
  private async forward(message: Message) {
3057
3201
  if (this.isManualCloseInFlight()) {
3058
3202
  return;
@@ -3076,22 +3220,34 @@ export class Client {
3076
3220
  this.getUser().userID,
3077
3221
  "own",
3078
3222
  );
3079
- for (const device of devices) {
3080
- if (device.deviceID === this.getDevice().deviceID) {
3081
- continue;
3082
- }
3083
- try {
3084
- await this.sendMailWithRecovery(
3085
- device,
3086
- this.getUser(),
3087
- msgBytes,
3088
- null,
3089
- copy.mailID,
3090
- true,
3091
- );
3092
- } catch {
3093
- /* best-effort per device; parallel handshakes share ephemeral state */
3094
- }
3223
+ const targetDevices = devices.filter(
3224
+ (device) => device.deviceID !== this.getDevice().deviceID,
3225
+ );
3226
+ for (
3227
+ let index = 0;
3228
+ index < targetDevices.length;
3229
+ index += MAIL_FANOUT_CONCURRENCY
3230
+ ) {
3231
+ const batch = targetDevices.slice(
3232
+ index,
3233
+ index + MAIL_FANOUT_CONCURRENCY,
3234
+ );
3235
+ await Promise.all(
3236
+ batch.map(async (device) => {
3237
+ try {
3238
+ await this.sendMailWithRecovery(
3239
+ device,
3240
+ this.getUser(),
3241
+ msgBytes,
3242
+ null,
3243
+ copy.mailID,
3244
+ true,
3245
+ );
3246
+ } catch {
3247
+ /* best-effort per device */
3248
+ }
3249
+ }),
3250
+ );
3095
3251
  }
3096
3252
  }
3097
3253
 
@@ -3467,8 +3623,6 @@ export class Client {
3467
3623
  return true;
3468
3624
  }
3469
3625
 
3470
- // ── Passkeys ────────────────────────────────────────────────────────
3471
-
3472
3626
  /**
3473
3627
  * Initializes the keyring. This must be called before anything else.
3474
3628
  */
@@ -3488,8 +3642,6 @@ export class Client {
3488
3642
  this.emitter.emit("ready");
3489
3643
  }
3490
3644
 
3491
- // ── Passkeys ────────────────────────────────────────────────────────
3492
-
3493
3645
  private initSocket() {
3494
3646
  try {
3495
3647
  if (!this.token) {
@@ -3673,16 +3825,6 @@ export class Client {
3673
3825
  await this.submitOTK(needs);
3674
3826
  }
3675
3827
 
3676
- private async newEphemeralKeys() {
3677
- if (!this.xKeyRing) {
3678
- if (this.manuallyClosing) {
3679
- return;
3680
- }
3681
- throw new Error("Key ring not initialized.");
3682
- }
3683
- this.xKeyRing.ephemeralKeys = await xBoxKeyPairAsync();
3684
- }
3685
-
3686
3828
  /**
3687
3829
  * Pipeline for decrypted messages — registered in `init`. After `close()` sets
3688
3830
  * `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
@@ -3710,21 +3852,6 @@ export class Client {
3710
3852
  this.scheduleRetentionPurge();
3711
3853
  };
3712
3854
 
3713
- private async passkeyApproveDeviceRequest(
3714
- requestID: string,
3715
- ): Promise<Device> {
3716
- const userID = this.getUser().userID;
3717
- const response = await this.http.post(
3718
- this.getHost() +
3719
- "/user/" +
3720
- userID +
3721
- "/passkey/devices/requests/" +
3722
- requestID +
3723
- "/approve",
3724
- );
3725
- return decodeHttpResponse(DeviceCodec, response.data);
3726
- }
3727
-
3728
3855
  private async passkeyDeleteDevice(deviceID: string): Promise<void> {
3729
3856
  const userID = this.getUser().userID;
3730
3857
  await this.http.delete(
@@ -3740,6 +3867,20 @@ export class Client {
3740
3867
  return decodeHttpResponse(DeviceArrayCodec, response.data);
3741
3868
  }
3742
3869
 
3870
+ private async passkeyRecoverDeviceRequest(
3871
+ requestID: string,
3872
+ ): Promise<Device> {
3873
+ const userID = this.getUser().userID;
3874
+ const response = await this.http.post(
3875
+ this.getHost() +
3876
+ "/user/" +
3877
+ userID +
3878
+ "/passkey/recover/devices/requests/" +
3879
+ requestID,
3880
+ );
3881
+ return decodeHttpResponse(DeviceCodec, response.data);
3882
+ }
3883
+
3743
3884
  private async passkeyRejectDeviceRequest(requestID: string): Promise<void> {
3744
3885
  const userID = this.getUser().userID;
3745
3886
  await this.http.post(
@@ -4818,6 +4959,16 @@ export class Client {
4818
4959
  }
4819
4960
  }
4820
4961
 
4962
+ private scheduleMailBatchFlush(): void {
4963
+ if (this.mailBatchFlushTimer) {
4964
+ return;
4965
+ }
4966
+ this.mailBatchFlushTimer = setTimeout(() => {
4967
+ this.mailBatchFlushTimer = null;
4968
+ void this.flushMailBatchQueue();
4969
+ }, MAIL_BATCH_FLUSH_DELAY_MS);
4970
+ }
4971
+
4821
4972
  private scheduleReconnect(): void {
4822
4973
  if (
4823
4974
  !this.autoReconnectEnabled ||
@@ -4963,27 +5114,49 @@ export class Client {
4963
5114
 
4964
5115
  let failCount = 0;
4965
5116
  let lastErr: unknown;
4966
- for (const device of stableDevices) {
4967
- const ownerRecord =
4968
- device.owner === myUserID
4969
- ? this.getUser()
4970
- : this.userRecords[device.owner];
4971
- if (!ownerRecord) {
4972
- failCount += 1;
4973
- continue;
5117
+ for (
5118
+ let index = 0;
5119
+ index < stableDevices.length;
5120
+ index += MAIL_FANOUT_CONCURRENCY
5121
+ ) {
5122
+ const batch = stableDevices.slice(
5123
+ index,
5124
+ index + MAIL_FANOUT_CONCURRENCY,
5125
+ );
5126
+ const results = await Promise.all(
5127
+ batch.map(async (device): Promise<undefined | unknown> => {
5128
+ const ownerRecord =
5129
+ device.owner === myUserID
5130
+ ? this.getUser()
5131
+ : this.userRecords[device.owner];
5132
+ if (!ownerRecord) {
5133
+ return new Error(
5134
+ `Missing owner record for device ${device.deviceID}.`,
5135
+ );
5136
+ }
5137
+ try {
5138
+ await this.sendMailWithRecovery(
5139
+ device,
5140
+ ownerRecord,
5141
+ msgBytes,
5142
+ uuidToUint8(channelID),
5143
+ mailID,
5144
+ false,
5145
+ );
5146
+ return undefined;
5147
+ } catch (e) {
5148
+ return e;
5149
+ }
5150
+ }),
5151
+ );
5152
+ for (const result of results) {
5153
+ if (result !== undefined) {
5154
+ lastErr = result;
5155
+ failCount += 1;
5156
+ }
4974
5157
  }
4975
- try {
4976
- await this.sendMailWithRecovery(
4977
- device,
4978
- ownerRecord,
4979
- msgBytes,
4980
- uuidToUint8(channelID),
4981
- mailID,
4982
- false,
4983
- );
4984
- } catch (e) {
4985
- lastErr = e;
4986
- failCount += 1;
5158
+ if (failCount === stableDevices.length) {
5159
+ break;
4987
5160
  }
4988
5161
  }
4989
5162
 
@@ -5138,35 +5311,7 @@ export class Client {
5138
5311
  await this.database.saveSession(persisted);
5139
5312
  this.sessionRecords[XUtils.encodeHex(session.publicKey)] = session;
5140
5313
 
5141
- await new Promise((res, rej) => {
5142
- const callback = (packedMsg: Uint8Array) => {
5143
- const [_header, receivedMsg] =
5144
- XUtils.unpackMessage(packedMsg);
5145
- if (receivedMsg.transmissionID === msgb.transmissionID) {
5146
- this.socket.off("message", callback);
5147
- const parsed = WSMessageSchema.safeParse(receivedMsg);
5148
- if (parsed.success && parsed.data.type === "success") {
5149
- res(parsed.data.data);
5150
- } else {
5151
- rej(
5152
- new Error(
5153
- "Mail delivery failed: " +
5154
- JSON.stringify(receivedMsg),
5155
- ),
5156
- );
5157
- }
5158
- }
5159
- };
5160
- this.socket.on("message", callback);
5161
- // See the matching block above (sendMail handshake):
5162
- // forward send failures to the outer promise so the
5163
- // caller doesn't hang waiting for a response we never
5164
- // sent.
5165
- this.send(msgb, hmac).catch((err: unknown) => {
5166
- this.socket.off("message", callback);
5167
- rej(err instanceof Error ? err : new Error(String(err)));
5168
- });
5169
- });
5314
+ await this.deliverMailResource(msgb, hmac, mail);
5170
5315
  } finally {
5171
5316
  this.sending.delete(device.deviceID);
5172
5317
  }
@@ -5265,38 +5410,60 @@ export class Client {
5265
5410
  // One logical DM fan-outs to multiple recipient devices. Reuse a
5266
5411
  // single mailID so local/UI dedupe treats it as one message.
5267
5412
  const messageMailID = uuid.v4();
5268
- for (const device of deviceList) {
5269
- try {
5270
- if (libvexDebugDmEnabled()) {
5271
- debugLibvexDm("sendMessage: sendMail start", {
5272
- mailID: messageMailID,
5273
- recipientDevice: device.deviceID,
5274
- });
5275
- }
5276
- await this.sendMailWithRecovery(
5277
- device,
5278
- userEntry,
5279
- XUtils.decodeUTF8(payload),
5280
- null,
5281
- messageMailID,
5282
- false,
5283
- );
5284
- if (libvexDebugDmEnabled()) {
5285
- debugLibvexDm("sendMessage: sendMail ok", {
5286
- recipientDevice: device.deviceID,
5287
- });
5288
- }
5289
- } catch (e) {
5290
- if (libvexDebugDmEnabled()) {
5291
- // eslint-disable-next-line no-console -- LIBVEX_DEBUG_DM only
5292
- console.error(
5293
- "[libvex:debug-dm] sendMessage: sendMail failed for device",
5294
- device.deviceID,
5295
- e,
5296
- );
5413
+ const msgBytes = XUtils.decodeUTF8(payload);
5414
+ for (
5415
+ let index = 0;
5416
+ index < deviceList.length;
5417
+ index += MAIL_FANOUT_CONCURRENCY
5418
+ ) {
5419
+ const batch = deviceList.slice(
5420
+ index,
5421
+ index + MAIL_FANOUT_CONCURRENCY,
5422
+ );
5423
+ const results = await Promise.all(
5424
+ batch.map(async (device): Promise<undefined | unknown> => {
5425
+ try {
5426
+ if (libvexDebugDmEnabled()) {
5427
+ debugLibvexDm("sendMessage: sendMail start", {
5428
+ mailID: messageMailID,
5429
+ recipientDevice: device.deviceID,
5430
+ });
5431
+ }
5432
+ await this.sendMailWithRecovery(
5433
+ device,
5434
+ userEntry,
5435
+ msgBytes,
5436
+ null,
5437
+ messageMailID,
5438
+ false,
5439
+ );
5440
+ if (libvexDebugDmEnabled()) {
5441
+ debugLibvexDm("sendMessage: sendMail ok", {
5442
+ recipientDevice: device.deviceID,
5443
+ });
5444
+ }
5445
+ return undefined;
5446
+ } catch (e) {
5447
+ if (libvexDebugDmEnabled()) {
5448
+ // eslint-disable-next-line no-console -- LIBVEX_DEBUG_DM only
5449
+ console.error(
5450
+ "[libvex:debug-dm] sendMessage: sendMail failed for device",
5451
+ device.deviceID,
5452
+ e,
5453
+ );
5454
+ }
5455
+ return e;
5456
+ }
5457
+ }),
5458
+ );
5459
+ for (const result of results) {
5460
+ if (result !== undefined) {
5461
+ lastErr = result;
5462
+ failCount += 1;
5297
5463
  }
5298
- lastErr = e;
5299
- failCount += 1;
5464
+ }
5465
+ if (failCount === deviceList.length) {
5466
+ break;
5300
5467
  }
5301
5468
  }
5302
5469
  if (failCount > 0) {