@vex-chat/libvex 6.7.0 → 7.0.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
@@ -705,8 +705,6 @@ export interface NotificationSubscriptionInput {
705
705
  * @public
706
706
  */
707
707
  export interface Passkeys {
708
- /** Approves a pending device-enrollment request using the passkey session. */
709
- approveDeviceRequest: (requestID: string) => Promise<Device>;
710
708
  /** Begin a public passkey authentication ceremony for `username`. */
711
709
  beginAuthentication: (username: string) => Promise<{
712
710
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- WebAuthn options shape varies per simplewebauthn version
@@ -748,6 +746,12 @@ export interface Passkeys {
748
746
  list: () => Promise<Passkey[]>;
749
747
  /** List all of the account's devices using the passkey session. */
750
748
  listDevices: () => Promise<Device[]>;
749
+ /**
750
+ * Recover the account onto a pending device using the passkey
751
+ * session. The server approves the pending device and revokes all
752
+ * previously-active devices for the account.
753
+ */
754
+ recoverDeviceRequest: (requestID: string) => Promise<Device>;
751
755
  /** Reject a pending device-enrollment request using the passkey session. */
752
756
  rejectDeviceRequest: (requestID: string) => Promise<void>;
753
757
  }
@@ -866,6 +870,9 @@ const messageSchema: z.ZodType<Message> = z.object({
866
870
  });
867
871
 
868
872
  const MESSAGE_BLOB_PREFIX = "vex-message:1\n";
873
+ const MAIL_FANOUT_CONCURRENCY = 8;
874
+ const MAIL_BATCH_MAX_SIZE = 32;
875
+ const MAIL_BATCH_FLUSH_DELAY_MS = 8;
869
876
 
870
877
  interface DecodedMessagePlaintext {
871
878
  extra?: null | string | undefined;
@@ -873,6 +880,27 @@ interface DecodedMessagePlaintext {
873
880
  retentionHintDays?: number | undefined;
874
881
  }
875
882
 
883
+ interface PendingMailBatchDelivery {
884
+ header: Uint8Array;
885
+ mail: MailWS;
886
+ msg: ResourceMsg;
887
+ reject: (err: unknown) => void;
888
+ resolve: () => void;
889
+ }
890
+
891
+ const mailBatchResponseSchema = z.object({
892
+ results: z.array(
893
+ z.object({
894
+ error: z.string().optional(),
895
+ index: z.number().int().nonnegative(),
896
+ mailID: z.string().optional(),
897
+ ok: z.boolean(),
898
+ recipient: z.string().optional(),
899
+ status: z.number().int().optional(),
900
+ }),
901
+ ),
902
+ });
903
+
876
904
  function decodeMessageBlob(body: string): DecodedMessagePlaintext {
877
905
  if (!body.startsWith(MESSAGE_BLOB_PREFIX)) {
878
906
  return { message: body };
@@ -1406,16 +1434,15 @@ export class Client {
1406
1434
  * Passkey ("recovery credential") methods.
1407
1435
  *
1408
1436
  * 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.
1437
+ * authenticate the owning user, list devices, delete devices, recover a
1438
+ * pending device enrollment, and reject pending device-enrollment
1439
+ * requests. They cannot send/decrypt mail.
1412
1440
  *
1413
1441
  * The host app drives the WebAuthn ceremony (e.g. via
1414
1442
  * `@simplewebauthn/browser`) and hands the JSON response to
1415
1443
  * `finish*`.
1416
1444
  */
1417
1445
  public passkeys: Passkeys = {
1418
- approveDeviceRequest: this.passkeyApproveDeviceRequest.bind(this),
1419
1446
  beginAuthentication: this.beginPasskeyAuthentication.bind(this),
1420
1447
  beginRegistration: this.beginPasskeyRegistration.bind(this),
1421
1448
  delete: this.deletePasskey.bind(this),
@@ -1424,6 +1451,7 @@ export class Client {
1424
1451
  finishRegistration: this.finishPasskeyRegistration.bind(this),
1425
1452
  list: this.listPasskeys.bind(this),
1426
1453
  listDevices: this.passkeyListDevices.bind(this),
1454
+ recoverDeviceRequest: this.passkeyRecoverDeviceRequest.bind(this),
1427
1455
  rejectDeviceRequest: this.passkeyRejectDeviceRequest.bind(this),
1428
1456
  };
1429
1457
 
@@ -1557,6 +1585,9 @@ export class Client {
1557
1585
  private localMessageRetentionDays: number;
1558
1586
  private localRetentionPurgeTimer: null | ReturnType<typeof setInterval> =
1559
1587
  null;
1588
+ private mailBatchFlushTimer: null | ReturnType<typeof setTimeout> = null;
1589
+ private readonly mailBatchQueue: PendingMailBatchDelivery[] = [];
1590
+ private mailBatchUnsupported = false;
1560
1591
  private readonly mailInterval?: NodeJS.Timeout;
1561
1592
 
1562
1593
  private manuallyClosing: boolean = false;
@@ -1879,6 +1910,14 @@ export class Client {
1879
1910
  if (this.mailInterval) {
1880
1911
  clearInterval(this.mailInterval);
1881
1912
  }
1913
+ if (this.mailBatchFlushTimer) {
1914
+ clearTimeout(this.mailBatchFlushTimer);
1915
+ this.mailBatchFlushTimer = null;
1916
+ }
1917
+ const pendingMailBatch = this.mailBatchQueue.splice(0);
1918
+ for (const pending of pendingMailBatch) {
1919
+ pending.reject(new Error("Client closed before mail batch sent."));
1920
+ }
1882
1921
  if (this.localRetentionPurgeTimer) {
1883
1922
  clearInterval(this.localRetentionPurgeTimer);
1884
1923
  this.localRetentionPurgeTimer = null;
@@ -2674,7 +2713,8 @@ export class Client {
2674
2713
  // my keys
2675
2714
  const IK_A = this.xKeyRing.identityKeys.secretKey;
2676
2715
  const IK_AP = this.xKeyRing.identityKeys.publicKey;
2677
- const EK_A = this.xKeyRing.ephemeralKeys.secretKey;
2716
+ const ephemeralKeys = await xBoxKeyPairAsync();
2717
+ const EK_A = ephemeralKeys.secretKey;
2678
2718
 
2679
2719
  const fips = this.cryptoProfile === "fips";
2680
2720
  // their keys — FIPS: `signKey` in bundle is the peer P-256 ECDH identity (raw, typically 65B).
@@ -2732,13 +2772,13 @@ export class Client {
2732
2772
  const cipher = await xSecretboxAsync(message, nonce, SK);
2733
2773
 
2734
2774
  const signKeyWire = fips ? IK_AP : this.signKeys.publicKey;
2735
- const ephKeyWire = this.xKeyRing.ephemeralKeys.publicKey;
2775
+ const ephKeyWire = ephemeralKeys.publicKey;
2736
2776
 
2737
2777
  const extra = fips
2738
2778
  ? encodeFipsInitialExtraV1(signKeyWire, ephKeyWire, PK, AD, IDX)
2739
2779
  : xConcat(
2740
2780
  this.signKeys.publicKey,
2741
- this.xKeyRing.ephemeralKeys.publicKey,
2781
+ ephemeralKeys.publicKey,
2742
2782
  PK,
2743
2783
  AD,
2744
2784
  IDX,
@@ -2768,9 +2808,6 @@ export class Client {
2768
2808
  type: "resource",
2769
2809
  };
2770
2810
 
2771
- // discard the ephemeral keys
2772
- await this.newEphemeralKeys();
2773
-
2774
2811
  const ratchet = await initRatchetSession(SK, "initiator");
2775
2812
  const sessionEntry: SessionSQL = {
2776
2813
  ...ratchet,
@@ -2816,37 +2853,7 @@ export class Client {
2816
2853
  this.emitter.emit("message", emitMsg);
2817
2854
  }
2818
2855
 
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
- });
2856
+ await this.deliverMailResource(msg, hmac, mail);
2850
2857
  });
2851
2858
  }
2852
2859
 
@@ -2886,6 +2893,59 @@ export class Client {
2886
2893
  private async deleteServer(serverID: string): Promise<void> {
2887
2894
  await this.http.delete(this.getHost() + "/server/" + serverID);
2888
2895
  }
2896
+ private deliverMailResource(
2897
+ msg: ResourceMsg,
2898
+ header: Uint8Array,
2899
+ mail: MailWS,
2900
+ ): Promise<void> {
2901
+ if (this.mailBatchUnsupported) {
2902
+ return this.deliverMailResourceOverSocket(msg, header);
2903
+ }
2904
+ return new Promise<void>((resolve, reject) => {
2905
+ this.mailBatchQueue.push({
2906
+ header,
2907
+ mail,
2908
+ msg,
2909
+ reject,
2910
+ resolve,
2911
+ });
2912
+ if (this.mailBatchQueue.length >= MAIL_BATCH_MAX_SIZE) {
2913
+ void this.flushMailBatchQueue();
2914
+ } else {
2915
+ this.scheduleMailBatchFlush();
2916
+ }
2917
+ });
2918
+ }
2919
+ private async deliverMailResourceOverSocket(
2920
+ msg: ResourceMsg,
2921
+ header: Uint8Array,
2922
+ ): Promise<void> {
2923
+ await new Promise<void>((res, rej) => {
2924
+ const callback = (packedMsg: Uint8Array) => {
2925
+ const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
2926
+ if (receivedMsg.transmissionID === msg.transmissionID) {
2927
+ this.socket.off("message", callback);
2928
+ const parsed = WSMessageSchema.safeParse(receivedMsg);
2929
+ if (parsed.success && parsed.data.type === "success") {
2930
+ res();
2931
+ } else {
2932
+ rej(
2933
+ new Error(
2934
+ "Mail delivery failed: " +
2935
+ JSON.stringify(receivedMsg),
2936
+ ),
2937
+ );
2938
+ }
2939
+ }
2940
+ };
2941
+ this.socket.on("message", callback);
2942
+ this.send(msg, header).catch((err: unknown) => {
2943
+ this.socket.off("message", callback);
2944
+ rej(err instanceof Error ? err : new Error(String(err)));
2945
+ });
2946
+ });
2947
+ }
2948
+
2889
2949
  private deviceListFailureDetail(err: unknown): string {
2890
2950
  if (!isHttpError(err)) {
2891
2951
  return "";
@@ -2899,6 +2959,7 @@ export class Client {
2899
2959
  }
2900
2960
  return "";
2901
2961
  }
2962
+
2902
2963
  /**
2903
2964
  * Gets a list of permissions for a server.
2904
2965
  *
@@ -3053,6 +3114,84 @@ export class Client {
3053
3114
  return decodeHttpResponse(PasskeyCodec, response.data);
3054
3115
  }
3055
3116
 
3117
+ private async flushMailBatchOverSocket(
3118
+ batch: PendingMailBatchDelivery[],
3119
+ ): Promise<void> {
3120
+ await Promise.all(
3121
+ batch.map(async (item) => {
3122
+ try {
3123
+ await this.deliverMailResourceOverSocket(
3124
+ item.msg,
3125
+ item.header,
3126
+ );
3127
+ item.resolve();
3128
+ } catch (err: unknown) {
3129
+ item.reject(err);
3130
+ }
3131
+ }),
3132
+ );
3133
+ }
3134
+
3135
+ private async flushMailBatchQueue(): Promise<void> {
3136
+ if (this.mailBatchFlushTimer) {
3137
+ clearTimeout(this.mailBatchFlushTimer);
3138
+ this.mailBatchFlushTimer = null;
3139
+ }
3140
+ const batch = this.mailBatchQueue.splice(0, MAIL_BATCH_MAX_SIZE);
3141
+ if (this.mailBatchQueue.length > 0) {
3142
+ this.scheduleMailBatchFlush();
3143
+ }
3144
+ if (batch.length === 0) {
3145
+ return;
3146
+ }
3147
+ if (this.mailBatchUnsupported) {
3148
+ await this.flushMailBatchOverSocket(batch);
3149
+ return;
3150
+ }
3151
+
3152
+ try {
3153
+ const response = await this.http.post(
3154
+ this.getHost() + "/mail/batch",
3155
+ msgpack.encode({
3156
+ mails: batch.map((item) => ({
3157
+ header: item.header,
3158
+ mail: item.mail,
3159
+ })),
3160
+ }),
3161
+ { headers: { "Content-Type": "application/msgpack" } },
3162
+ );
3163
+ const decoded = mailBatchResponseSchema.parse(
3164
+ msgpack.decode(new Uint8Array(response.data)),
3165
+ );
3166
+ const resultsByIndex = new Map(
3167
+ decoded.results.map((result) => [result.index, result]),
3168
+ );
3169
+ for (const [index, item] of batch.entries()) {
3170
+ const result = resultsByIndex.get(index);
3171
+ if (result?.ok === true) {
3172
+ item.resolve();
3173
+ continue;
3174
+ }
3175
+ item.reject(
3176
+ new Error(
3177
+ "Mail delivery failed: " +
3178
+ (result?.error ??
3179
+ `missing batch result for index ${String(index)}`),
3180
+ ),
3181
+ );
3182
+ }
3183
+ } catch (err: unknown) {
3184
+ if (isHttpError(err) && err.response?.status === 404) {
3185
+ this.mailBatchUnsupported = true;
3186
+ await this.flushMailBatchOverSocket(batch);
3187
+ return;
3188
+ }
3189
+ for (const item of batch) {
3190
+ item.reject(err);
3191
+ }
3192
+ }
3193
+ }
3194
+
3056
3195
  private async forward(message: Message) {
3057
3196
  if (this.isManualCloseInFlight()) {
3058
3197
  return;
@@ -3076,22 +3215,34 @@ export class Client {
3076
3215
  this.getUser().userID,
3077
3216
  "own",
3078
3217
  );
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
- }
3218
+ const targetDevices = devices.filter(
3219
+ (device) => device.deviceID !== this.getDevice().deviceID,
3220
+ );
3221
+ for (
3222
+ let index = 0;
3223
+ index < targetDevices.length;
3224
+ index += MAIL_FANOUT_CONCURRENCY
3225
+ ) {
3226
+ const batch = targetDevices.slice(
3227
+ index,
3228
+ index + MAIL_FANOUT_CONCURRENCY,
3229
+ );
3230
+ await Promise.all(
3231
+ batch.map(async (device) => {
3232
+ try {
3233
+ await this.sendMailWithRecovery(
3234
+ device,
3235
+ this.getUser(),
3236
+ msgBytes,
3237
+ null,
3238
+ copy.mailID,
3239
+ true,
3240
+ );
3241
+ } catch {
3242
+ /* best-effort per device */
3243
+ }
3244
+ }),
3245
+ );
3095
3246
  }
3096
3247
  }
3097
3248
 
@@ -3467,8 +3618,6 @@ export class Client {
3467
3618
  return true;
3468
3619
  }
3469
3620
 
3470
- // ── Passkeys ────────────────────────────────────────────────────────
3471
-
3472
3621
  /**
3473
3622
  * Initializes the keyring. This must be called before anything else.
3474
3623
  */
@@ -3488,8 +3637,6 @@ export class Client {
3488
3637
  this.emitter.emit("ready");
3489
3638
  }
3490
3639
 
3491
- // ── Passkeys ────────────────────────────────────────────────────────
3492
-
3493
3640
  private initSocket() {
3494
3641
  try {
3495
3642
  if (!this.token) {
@@ -3673,16 +3820,6 @@ export class Client {
3673
3820
  await this.submitOTK(needs);
3674
3821
  }
3675
3822
 
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
3823
  /**
3687
3824
  * Pipeline for decrypted messages — registered in `init`. After `close()` sets
3688
3825
  * `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
@@ -3710,21 +3847,6 @@ export class Client {
3710
3847
  this.scheduleRetentionPurge();
3711
3848
  };
3712
3849
 
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
3850
  private async passkeyDeleteDevice(deviceID: string): Promise<void> {
3729
3851
  const userID = this.getUser().userID;
3730
3852
  await this.http.delete(
@@ -3740,6 +3862,20 @@ export class Client {
3740
3862
  return decodeHttpResponse(DeviceArrayCodec, response.data);
3741
3863
  }
3742
3864
 
3865
+ private async passkeyRecoverDeviceRequest(
3866
+ requestID: string,
3867
+ ): Promise<Device> {
3868
+ const userID = this.getUser().userID;
3869
+ const response = await this.http.post(
3870
+ this.getHost() +
3871
+ "/user/" +
3872
+ userID +
3873
+ "/passkey/recover/devices/requests/" +
3874
+ requestID,
3875
+ );
3876
+ return decodeHttpResponse(DeviceCodec, response.data);
3877
+ }
3878
+
3743
3879
  private async passkeyRejectDeviceRequest(requestID: string): Promise<void> {
3744
3880
  const userID = this.getUser().userID;
3745
3881
  await this.http.post(
@@ -4818,6 +4954,16 @@ export class Client {
4818
4954
  }
4819
4955
  }
4820
4956
 
4957
+ private scheduleMailBatchFlush(): void {
4958
+ if (this.mailBatchFlushTimer) {
4959
+ return;
4960
+ }
4961
+ this.mailBatchFlushTimer = setTimeout(() => {
4962
+ this.mailBatchFlushTimer = null;
4963
+ void this.flushMailBatchQueue();
4964
+ }, MAIL_BATCH_FLUSH_DELAY_MS);
4965
+ }
4966
+
4821
4967
  private scheduleReconnect(): void {
4822
4968
  if (
4823
4969
  !this.autoReconnectEnabled ||
@@ -4963,27 +5109,49 @@ export class Client {
4963
5109
 
4964
5110
  let failCount = 0;
4965
5111
  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;
5112
+ for (
5113
+ let index = 0;
5114
+ index < stableDevices.length;
5115
+ index += MAIL_FANOUT_CONCURRENCY
5116
+ ) {
5117
+ const batch = stableDevices.slice(
5118
+ index,
5119
+ index + MAIL_FANOUT_CONCURRENCY,
5120
+ );
5121
+ const results = await Promise.all(
5122
+ batch.map(async (device): Promise<undefined | unknown> => {
5123
+ const ownerRecord =
5124
+ device.owner === myUserID
5125
+ ? this.getUser()
5126
+ : this.userRecords[device.owner];
5127
+ if (!ownerRecord) {
5128
+ return new Error(
5129
+ `Missing owner record for device ${device.deviceID}.`,
5130
+ );
5131
+ }
5132
+ try {
5133
+ await this.sendMailWithRecovery(
5134
+ device,
5135
+ ownerRecord,
5136
+ msgBytes,
5137
+ uuidToUint8(channelID),
5138
+ mailID,
5139
+ false,
5140
+ );
5141
+ return undefined;
5142
+ } catch (e) {
5143
+ return e;
5144
+ }
5145
+ }),
5146
+ );
5147
+ for (const result of results) {
5148
+ if (result !== undefined) {
5149
+ lastErr = result;
5150
+ failCount += 1;
5151
+ }
4974
5152
  }
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;
5153
+ if (failCount === stableDevices.length) {
5154
+ break;
4987
5155
  }
4988
5156
  }
4989
5157
 
@@ -5138,35 +5306,7 @@ export class Client {
5138
5306
  await this.database.saveSession(persisted);
5139
5307
  this.sessionRecords[XUtils.encodeHex(session.publicKey)] = session;
5140
5308
 
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
- });
5309
+ await this.deliverMailResource(msgb, hmac, mail);
5170
5310
  } finally {
5171
5311
  this.sending.delete(device.deviceID);
5172
5312
  }
@@ -5265,38 +5405,60 @@ export class Client {
5265
5405
  // One logical DM fan-outs to multiple recipient devices. Reuse a
5266
5406
  // single mailID so local/UI dedupe treats it as one message.
5267
5407
  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
- );
5408
+ const msgBytes = XUtils.decodeUTF8(payload);
5409
+ for (
5410
+ let index = 0;
5411
+ index < deviceList.length;
5412
+ index += MAIL_FANOUT_CONCURRENCY
5413
+ ) {
5414
+ const batch = deviceList.slice(
5415
+ index,
5416
+ index + MAIL_FANOUT_CONCURRENCY,
5417
+ );
5418
+ const results = await Promise.all(
5419
+ batch.map(async (device): Promise<undefined | unknown> => {
5420
+ try {
5421
+ if (libvexDebugDmEnabled()) {
5422
+ debugLibvexDm("sendMessage: sendMail start", {
5423
+ mailID: messageMailID,
5424
+ recipientDevice: device.deviceID,
5425
+ });
5426
+ }
5427
+ await this.sendMailWithRecovery(
5428
+ device,
5429
+ userEntry,
5430
+ msgBytes,
5431
+ null,
5432
+ messageMailID,
5433
+ false,
5434
+ );
5435
+ if (libvexDebugDmEnabled()) {
5436
+ debugLibvexDm("sendMessage: sendMail ok", {
5437
+ recipientDevice: device.deviceID,
5438
+ });
5439
+ }
5440
+ return undefined;
5441
+ } catch (e) {
5442
+ if (libvexDebugDmEnabled()) {
5443
+ // eslint-disable-next-line no-console -- LIBVEX_DEBUG_DM only
5444
+ console.error(
5445
+ "[libvex:debug-dm] sendMessage: sendMail failed for device",
5446
+ device.deviceID,
5447
+ e,
5448
+ );
5449
+ }
5450
+ return e;
5451
+ }
5452
+ }),
5453
+ );
5454
+ for (const result of results) {
5455
+ if (result !== undefined) {
5456
+ lastErr = result;
5457
+ failCount += 1;
5297
5458
  }
5298
- lastErr = e;
5299
- failCount += 1;
5459
+ }
5460
+ if (failCount === deviceList.length) {
5461
+ break;
5300
5462
  }
5301
5463
  }
5302
5464
  if (failCount > 0) {