@vex-chat/libvex 5.4.0 → 5.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/README.md +25 -23
- package/dist/Client.d.ts +113 -77
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +311 -234
- package/dist/Client.js.map +1 -1
- package/dist/__tests__/harness/memory-storage.d.ts +1 -1
- package/dist/__tests__/harness/memory-storage.d.ts.map +1 -1
- package/dist/__tests__/harness/memory-storage.js +1 -1
- package/dist/__tests__/harness/memory-storage.js.map +1 -1
- package/dist/codecs.d.ts +118 -0
- package/dist/codecs.d.ts.map +1 -1
- package/dist/codecs.js +41 -0
- package/dist/codecs.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/storage/node/http-agents.d.ts +1 -1
- package/dist/storage/node/http-agents.d.ts.map +1 -1
- package/dist/storage/node/http-agents.js +4 -4
- package/dist/storage/node/http-agents.js.map +1 -1
- package/dist/storage/sqlite.d.ts +8 -8
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +16 -16
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/utils/fipsMailExtra.d.ts +9 -9
- package/dist/utils/fipsMailExtra.d.ts.map +1 -1
- package/dist/utils/fipsMailExtra.js +47 -47
- package/dist/utils/fipsMailExtra.js.map +1 -1
- package/dist/utils/resolveAtRestAesKey.js +1 -1
- package/dist/utils/resolveAtRestAesKey.js.map +1 -1
- package/package.json +134 -152
- package/src/Client.ts +452 -306
- package/src/__tests__/harness/memory-storage.ts +1 -1
- package/src/__tests__/harness/shared-suite.ts +177 -177
- package/src/codecs.ts +52 -0
- package/src/index.ts +4 -0
- package/src/storage/node/http-agents.ts +7 -7
- package/src/storage/sqlite.ts +23 -23
- package/src/utils/fipsMailExtra.ts +80 -80
- package/src/utils/resolveAtRestAesKey.ts +1 -1
package/dist/Client.js
CHANGED
|
@@ -11,12 +11,49 @@ import * as uuid from "uuid";
|
|
|
11
11
|
import { z } from "zod/v4";
|
|
12
12
|
import { WebSocketAdapter } from "./transport/websocket.js";
|
|
13
13
|
import { decodeFipsInitialExtraV1, decodeFipsSubsequentExtraV1, encodeFipsInitialExtraV1, encodeFipsSubsequentExtraV1, fipsP256AdFromIdentityPubs, fipsP256PreKeySignPayload, isFipsInitialExtraV1, isFipsSubsequentExtraV1, } from "./utils/fipsMailExtra.js";
|
|
14
|
-
function
|
|
15
|
-
|
|
14
|
+
function debugLibvexDm(msg, data) {
|
|
15
|
+
if (!libvexDebugDmEnabled()) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const payload = data ? `${msg} ${JSON.stringify(data)}` : msg;
|
|
19
|
+
// eslint-disable-next-line no-console -- gated by LIBVEX_DEBUG_DM; remove when debugging is done
|
|
20
|
+
console.error(`[libvex:debug-dm] ${payload}`);
|
|
16
21
|
}
|
|
17
22
|
function isRecord(x) {
|
|
18
23
|
return typeof x === "object" && x !== null;
|
|
19
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Set `LIBVEX_DEBUG_DM=1` (e.g. in vitest / shell) to log DM multi-device / X3DH paths.
|
|
27
|
+
* Uses indirect `globalThis` lookup so the bare `process` global never appears in
|
|
28
|
+
* source that the platform-guard plugin scans (browser/RN/Tauri).
|
|
29
|
+
*/
|
|
30
|
+
function libvexDebugDmEnabled() {
|
|
31
|
+
try {
|
|
32
|
+
const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
|
|
33
|
+
if (!g) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
const proc = typeof g.get === "function" ? g.get() : g.value;
|
|
37
|
+
if (typeof proc !== "object" || proc === null) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
|
|
41
|
+
if (!envDesc) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
const env = typeof envDesc.get === "function" ? envDesc.get() : envDesc.value;
|
|
45
|
+
if (typeof env !== "object" || env === null) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return Reflect.get(env, "LIBVEX_DEBUG_DM") === "1";
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function sleep(ms) {
|
|
55
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
56
|
+
}
|
|
20
57
|
/**
|
|
21
58
|
* Spire 5+ JSON error bodies use `{ "error": { "message", "requestId"?, "details"? } }`.
|
|
22
59
|
* Responses are `arraybuffer` — decode UTF-8 and parse for a one-line `Error` message
|
|
@@ -69,45 +106,8 @@ function spireErrorBodyMessage(data, max = 8_000) {
|
|
|
69
106
|
}
|
|
70
107
|
return t.length > max ? t.slice(0, max) + "…" : t;
|
|
71
108
|
}
|
|
72
|
-
/**
|
|
73
|
-
* Set `LIBVEX_DEBUG_DM=1` (e.g. in vitest / shell) to log DM multi-device / X3DH paths.
|
|
74
|
-
* Uses indirect `globalThis` lookup so the bare `process` global never appears in
|
|
75
|
-
* source that the platform-guard plugin scans (browser/RN/Tauri).
|
|
76
|
-
*/
|
|
77
|
-
function libvexDebugDmEnabled() {
|
|
78
|
-
try {
|
|
79
|
-
const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
|
|
80
|
-
if (!g) {
|
|
81
|
-
return false;
|
|
82
|
-
}
|
|
83
|
-
const proc = typeof g.get === "function" ? g.get() : g.value;
|
|
84
|
-
if (typeof proc !== "object" || proc === null) {
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
87
|
-
const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
|
|
88
|
-
if (!envDesc) {
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
const env = typeof envDesc.get === "function" ? envDesc.get() : envDesc.value;
|
|
92
|
-
if (typeof env !== "object" || env === null) {
|
|
93
|
-
return false;
|
|
94
|
-
}
|
|
95
|
-
return Reflect.get(env, "LIBVEX_DEBUG_DM") === "1";
|
|
96
|
-
}
|
|
97
|
-
catch {
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
function debugLibvexDm(msg, data) {
|
|
102
|
-
if (!libvexDebugDmEnabled()) {
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
const payload = data ? `${msg} ${JSON.stringify(data)}` : msg;
|
|
106
|
-
// eslint-disable-next-line no-console -- gated by LIBVEX_DEBUG_DM; remove when debugging is done
|
|
107
|
-
console.error(`[libvex:debug-dm] ${payload}`);
|
|
108
|
-
}
|
|
109
109
|
import { msgpack } from "./codec.js";
|
|
110
|
-
import { ActionTokenCodec, AuthResponseCodec, ChannelArrayCodec, ChannelCodec, ConnectResponseCodec, decodeAxios, DeviceArrayCodec, DeviceChallengeCodec, DeviceCodec, EmojiArrayCodec, EmojiCodec, FileSQLCodec, InviteArrayCodec, InviteCodec, KeyBundleCodec, OtkCountCodec, PermissionArrayCodec, PermissionCodec, ServerArrayCodec, ServerCodec, UserArrayCodec, UserCodec, WhoamiCodec, } from "./codecs.js";
|
|
110
|
+
import { ActionTokenCodec, AuthResponseCodec, ChannelArrayCodec, ChannelCodec, ConnectResponseCodec, decodeAxios, DeviceArrayCodec, DeviceChallengeCodec, DeviceCodec, DeviceRegistrationResultCodec, EmojiArrayCodec, EmojiCodec, FileSQLCodec, InviteArrayCodec, InviteCodec, KeyBundleCodec, OtkCountCodec, PendingDeviceRequestArrayCodec, PendingDeviceRequestCodec, PermissionArrayCodec, PermissionCodec, ServerArrayCodec, ServerCodec, UserArrayCodec, UserCodec, WhoamiCodec, } from "./codecs.js";
|
|
111
111
|
import { capitalize } from "./utils/capitalize.js";
|
|
112
112
|
import { sqlSessionToCrypto } from "./utils/sqlSessionToCrypto.js";
|
|
113
113
|
import { uuidToUint8 } from "./utils/uint8uuid.js";
|
|
@@ -133,6 +133,14 @@ const mailInboxEntry = z.tuple([
|
|
|
133
133
|
MailWSSchema,
|
|
134
134
|
z.string(),
|
|
135
135
|
]);
|
|
136
|
+
const deviceRequestNotifyData = z.object({
|
|
137
|
+
requestID: z.string(),
|
|
138
|
+
status: z.union([
|
|
139
|
+
z.literal("approved"),
|
|
140
|
+
z.literal("pending"),
|
|
141
|
+
z.literal("rejected"),
|
|
142
|
+
]),
|
|
143
|
+
});
|
|
136
144
|
export class Client {
|
|
137
145
|
/**
|
|
138
146
|
* Decrypts a secret key from encrypted data produced by encryptKeyData().
|
|
@@ -193,8 +201,12 @@ export class Client {
|
|
|
193
201
|
* Device management methods.
|
|
194
202
|
*/
|
|
195
203
|
devices = {
|
|
204
|
+
approveRequest: this.approveDeviceRequest.bind(this),
|
|
196
205
|
delete: this.deleteDevice.bind(this),
|
|
206
|
+
getRequest: this.getDeviceRegistrationRequest.bind(this),
|
|
207
|
+
listRequests: this.listDeviceRegistrationRequests.bind(this),
|
|
197
208
|
register: this.registerDevice.bind(this),
|
|
209
|
+
rejectRequest: this.rejectDeviceRequest.bind(this),
|
|
198
210
|
retrieve: this.getDeviceByID.bind(this),
|
|
199
211
|
};
|
|
200
212
|
/**
|
|
@@ -397,6 +409,7 @@ export class Client {
|
|
|
397
409
|
*/
|
|
398
410
|
retrieve: this.fetchUser.bind(this),
|
|
399
411
|
};
|
|
412
|
+
cryptoProfile;
|
|
400
413
|
database;
|
|
401
414
|
dbPath;
|
|
402
415
|
device;
|
|
@@ -407,23 +420,18 @@ export class Client {
|
|
|
407
420
|
firstMailFetch = true;
|
|
408
421
|
forwarded = new Set();
|
|
409
422
|
host;
|
|
410
|
-
|
|
411
|
-
* Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
|
|
412
|
-
* Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
|
|
413
|
-
*/
|
|
414
|
-
nodeHttpAgents;
|
|
423
|
+
http;
|
|
415
424
|
/** Cancels in-flight axios work on `close()` so `postAuth`/`getMail` cannot hang forever. */
|
|
416
425
|
httpAbortController = new AbortController();
|
|
417
|
-
http;
|
|
418
426
|
idKeys;
|
|
419
427
|
isAlive = true;
|
|
420
428
|
mailInterval;
|
|
421
429
|
manuallyClosing = false;
|
|
422
430
|
/**
|
|
423
|
-
*
|
|
424
|
-
* `
|
|
431
|
+
* Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
|
|
432
|
+
* Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
|
|
425
433
|
*/
|
|
426
|
-
|
|
434
|
+
nodeHttpAgents;
|
|
427
435
|
/* Retrieves the userID with the user identifier.
|
|
428
436
|
user identifier is checked for userID, then signkey,
|
|
429
437
|
and finally falls back to username. */
|
|
@@ -431,6 +439,11 @@ export class Client {
|
|
|
431
439
|
notFoundUsers = new Map();
|
|
432
440
|
options;
|
|
433
441
|
pingInterval = null;
|
|
442
|
+
/**
|
|
443
|
+
* Bumped when the WebSocket is torn down and re-opened so the previous
|
|
444
|
+
* `postAuth` loop exits instead of overlapping a new one.
|
|
445
|
+
*/
|
|
446
|
+
postAuthVersion = 0;
|
|
434
447
|
prefixes;
|
|
435
448
|
reading = false;
|
|
436
449
|
seenMailIDs = new Set();
|
|
@@ -442,7 +455,6 @@ export class Client {
|
|
|
442
455
|
user;
|
|
443
456
|
userRecords = {};
|
|
444
457
|
xKeyRing;
|
|
445
|
-
cryptoProfile;
|
|
446
458
|
constructor(material, options, storage) {
|
|
447
459
|
this.options = options;
|
|
448
460
|
this.cryptoProfile = material.cryptoProfile;
|
|
@@ -605,27 +617,6 @@ export class Client {
|
|
|
605
617
|
static getMnemonic(session) {
|
|
606
618
|
return xMnemonic(xKDF(XUtils.decodeHex(session.fingerprint)));
|
|
607
619
|
}
|
|
608
|
-
/**
|
|
609
|
-
* True when running under Node (has `process.versions`).
|
|
610
|
-
* Uses indirect lookup so the bare `process` global never appears in
|
|
611
|
-
* source that the platform-guard plugin scans.
|
|
612
|
-
*/
|
|
613
|
-
static isNodeRuntime() {
|
|
614
|
-
try {
|
|
615
|
-
const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
|
|
616
|
-
if (!g)
|
|
617
|
-
return false;
|
|
618
|
-
const proc = typeof g.get === "function" ? g.get() : g.value;
|
|
619
|
-
if (typeof proc !== "object" || proc === null) {
|
|
620
|
-
return false;
|
|
621
|
-
}
|
|
622
|
-
return ("versions" in proc &&
|
|
623
|
-
typeof proc.versions === "object");
|
|
624
|
-
}
|
|
625
|
-
catch {
|
|
626
|
-
return false;
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
620
|
/**
|
|
630
621
|
* Browser-safe NODE_ENV accessor.
|
|
631
622
|
* Uses indirect lookup so the bare `process` global never appears in
|
|
@@ -663,12 +654,25 @@ export class Client {
|
|
|
663
654
|
}
|
|
664
655
|
}
|
|
665
656
|
/**
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
657
|
+
* True when running under Node (has `process.versions`).
|
|
658
|
+
* Uses indirect lookup so the bare `process` global never appears in
|
|
659
|
+
* source that the platform-guard plugin scans.
|
|
669
660
|
*/
|
|
670
|
-
|
|
671
|
-
|
|
661
|
+
static isNodeRuntime() {
|
|
662
|
+
try {
|
|
663
|
+
const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
|
|
664
|
+
if (!g)
|
|
665
|
+
return false;
|
|
666
|
+
const proc = typeof g.get === "function" ? g.get() : g.value;
|
|
667
|
+
if (typeof proc !== "object" || proc === null) {
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
670
|
+
return ("versions" in proc &&
|
|
671
|
+
typeof proc.versions === "object");
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
672
676
|
}
|
|
673
677
|
/**
|
|
674
678
|
* Closes the client — disconnects the WebSocket, shuts down storage,
|
|
@@ -723,57 +727,6 @@ export class Client {
|
|
|
723
727
|
await new Promise((r) => setTimeout(r, 0));
|
|
724
728
|
await this.negotiateOTK();
|
|
725
729
|
}
|
|
726
|
-
/**
|
|
727
|
-
* Tears down the current WebSocket and opens a new one, keeping the same
|
|
728
|
-
* session (user + device in storage). Restarts the post-auth mail loop.
|
|
729
|
-
* Use for long-running processes or e2e where a fresh socket matches a
|
|
730
|
-
* newly-registered second device.
|
|
731
|
-
*/
|
|
732
|
-
async reconnectWebsocket() {
|
|
733
|
-
this.postAuthVersion++;
|
|
734
|
-
if (this.pingInterval) {
|
|
735
|
-
clearInterval(this.pingInterval);
|
|
736
|
-
this.pingInterval = null;
|
|
737
|
-
}
|
|
738
|
-
this.socket.close();
|
|
739
|
-
try {
|
|
740
|
-
await new Promise((resolve, reject) => {
|
|
741
|
-
const t = setTimeout(() => {
|
|
742
|
-
this.off("connected", onC);
|
|
743
|
-
reject(new Error("reconnectWebsocket: timed out waiting for authorized"));
|
|
744
|
-
}, 15_000);
|
|
745
|
-
const onC = () => {
|
|
746
|
-
clearTimeout(t);
|
|
747
|
-
this.off("connected", onC);
|
|
748
|
-
resolve();
|
|
749
|
-
};
|
|
750
|
-
this.on("connected", onC);
|
|
751
|
-
try {
|
|
752
|
-
this.initSocket();
|
|
753
|
-
}
|
|
754
|
-
catch (err) {
|
|
755
|
-
clearTimeout(t);
|
|
756
|
-
this.off("connected", onC);
|
|
757
|
-
const e = err instanceof Error
|
|
758
|
-
? err
|
|
759
|
-
: new Error(String(err), { cause: err });
|
|
760
|
-
reject(e);
|
|
761
|
-
}
|
|
762
|
-
});
|
|
763
|
-
}
|
|
764
|
-
catch (e) {
|
|
765
|
-
throw e instanceof Error ? e : new Error(String(e), { cause: e });
|
|
766
|
-
}
|
|
767
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
768
|
-
await this.negotiateOTK();
|
|
769
|
-
}
|
|
770
|
-
/**
|
|
771
|
-
* Triggers an immediate inbox sync by fetching `/mail` once.
|
|
772
|
-
* Useful on mobile foreground resume where background work may pause.
|
|
773
|
-
*/
|
|
774
|
-
async syncInboxNow() {
|
|
775
|
-
await this.getMail();
|
|
776
|
-
}
|
|
777
730
|
/**
|
|
778
731
|
* Delete all local data — message history, encryption sessions, and prekeys.
|
|
779
732
|
* Closes the client afterward. Credentials (keychain) must be cleared by the consumer.
|
|
@@ -899,6 +852,50 @@ export class Client {
|
|
|
899
852
|
this.emitter.once(event, fn, context);
|
|
900
853
|
return this;
|
|
901
854
|
}
|
|
855
|
+
/**
|
|
856
|
+
* Tears down the current WebSocket and opens a new one, keeping the same
|
|
857
|
+
* session (user + device in storage). Restarts the post-auth mail loop.
|
|
858
|
+
* Use for long-running processes or e2e where a fresh socket matches a
|
|
859
|
+
* newly-registered second device.
|
|
860
|
+
*/
|
|
861
|
+
async reconnectWebsocket() {
|
|
862
|
+
this.postAuthVersion++;
|
|
863
|
+
if (this.pingInterval) {
|
|
864
|
+
clearInterval(this.pingInterval);
|
|
865
|
+
this.pingInterval = null;
|
|
866
|
+
}
|
|
867
|
+
this.socket.close();
|
|
868
|
+
try {
|
|
869
|
+
await new Promise((resolve, reject) => {
|
|
870
|
+
const t = setTimeout(() => {
|
|
871
|
+
this.off("connected", onC);
|
|
872
|
+
reject(new Error("reconnectWebsocket: timed out waiting for authorized"));
|
|
873
|
+
}, 15_000);
|
|
874
|
+
const onC = () => {
|
|
875
|
+
clearTimeout(t);
|
|
876
|
+
this.off("connected", onC);
|
|
877
|
+
resolve();
|
|
878
|
+
};
|
|
879
|
+
this.on("connected", onC);
|
|
880
|
+
try {
|
|
881
|
+
this.initSocket();
|
|
882
|
+
}
|
|
883
|
+
catch (err) {
|
|
884
|
+
clearTimeout(t);
|
|
885
|
+
this.off("connected", onC);
|
|
886
|
+
const e = err instanceof Error
|
|
887
|
+
? err
|
|
888
|
+
: new Error(String(err), { cause: err });
|
|
889
|
+
reject(e);
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
catch (e) {
|
|
894
|
+
throw e instanceof Error ? e : new Error(String(e), { cause: e });
|
|
895
|
+
}
|
|
896
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
897
|
+
await this.negotiateOTK();
|
|
898
|
+
}
|
|
902
899
|
/**
|
|
903
900
|
* Registers a new account on the server.
|
|
904
901
|
*
|
|
@@ -956,6 +953,13 @@ export class Client {
|
|
|
956
953
|
this.emitter.removeAllListeners(event);
|
|
957
954
|
return this;
|
|
958
955
|
}
|
|
956
|
+
/**
|
|
957
|
+
* Triggers an immediate inbox sync by fetching `/mail` once.
|
|
958
|
+
* Useful on mobile foreground resume where background work may pause.
|
|
959
|
+
*/
|
|
960
|
+
async syncInboxNow() {
|
|
961
|
+
await this.getMail();
|
|
962
|
+
}
|
|
959
963
|
/**
|
|
960
964
|
* Returns a compact `<username><deviceID>` debug label.
|
|
961
965
|
*/
|
|
@@ -981,6 +985,24 @@ export class Client {
|
|
|
981
985
|
const whoami = decodeAxios(WhoamiCodec, res.data);
|
|
982
986
|
return whoami;
|
|
983
987
|
}
|
|
988
|
+
async approveDeviceRequest(requestID) {
|
|
989
|
+
const req = await this.getDeviceRegistrationRequest(requestID);
|
|
990
|
+
if (!req) {
|
|
991
|
+
throw new Error("Device approval request not found.");
|
|
992
|
+
}
|
|
993
|
+
if (req.status !== "pending") {
|
|
994
|
+
throw new Error("Device approval request is not pending: " + req.status);
|
|
995
|
+
}
|
|
996
|
+
const signed = XUtils.encodeHex(await xSignAsync(XUtils.decodeUTF8(requestID), this.signKeys.secretKey));
|
|
997
|
+
const response = await this.http.post(this.prefixes.HTTP +
|
|
998
|
+
this.host +
|
|
999
|
+
"/user/" +
|
|
1000
|
+
this.getUser().userID +
|
|
1001
|
+
"/devices/requests/" +
|
|
1002
|
+
requestID +
|
|
1003
|
+
"/approve", msgpack.encode({ signed }), { headers: { "Content-Type": "application/msgpack" } });
|
|
1004
|
+
return decodeAxios(DeviceCodec, response.data);
|
|
1005
|
+
}
|
|
984
1006
|
censorPreKey(preKey) {
|
|
985
1007
|
if (!preKey.index) {
|
|
986
1008
|
throw new Error("Key index is required.");
|
|
@@ -1061,25 +1083,6 @@ export class Client {
|
|
|
1061
1083
|
const res = await this.http.post(this.getHost() + "/server/" + globalThis.btoa(name));
|
|
1062
1084
|
return decodeAxios(ServerCodec, res.data);
|
|
1063
1085
|
}
|
|
1064
|
-
/**
|
|
1065
|
-
* `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
|
|
1066
|
-
* active profile. When several {@link Client} instances use different
|
|
1067
|
-
* `cryptoProfile` values, scope the global to this instance for the duration
|
|
1068
|
-
* of that crypto work.
|
|
1069
|
-
*/
|
|
1070
|
-
async runWithThisCryptoProfile(fn) {
|
|
1071
|
-
const prev = getCryptoProfile();
|
|
1072
|
-
if (prev === this.cryptoProfile) {
|
|
1073
|
-
return await fn();
|
|
1074
|
-
}
|
|
1075
|
-
setCryptoProfile(this.cryptoProfile);
|
|
1076
|
-
try {
|
|
1077
|
-
return await fn();
|
|
1078
|
-
}
|
|
1079
|
-
finally {
|
|
1080
|
-
setCryptoProfile(prev);
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
1086
|
async createSession(device, user, message, group,
|
|
1084
1087
|
/* this is passed through if the first message is
|
|
1085
1088
|
part of a group message */
|
|
@@ -1255,6 +1258,19 @@ export class Client {
|
|
|
1255
1258
|
async deleteServer(serverID) {
|
|
1256
1259
|
await this.http.delete(this.getHost() + "/server/" + serverID);
|
|
1257
1260
|
}
|
|
1261
|
+
deviceListFailureDetail(err) {
|
|
1262
|
+
if (!isAxiosError(err)) {
|
|
1263
|
+
return "";
|
|
1264
|
+
}
|
|
1265
|
+
const st = err.response?.status;
|
|
1266
|
+
if (typeof st === "number") {
|
|
1267
|
+
return ` (HTTP ${String(st)})`;
|
|
1268
|
+
}
|
|
1269
|
+
if (err.code !== undefined) {
|
|
1270
|
+
return ` (${err.code})`;
|
|
1271
|
+
}
|
|
1272
|
+
return "";
|
|
1273
|
+
}
|
|
1258
1274
|
/**
|
|
1259
1275
|
* Gets a list of permissions for a server.
|
|
1260
1276
|
*
|
|
@@ -1295,6 +1311,50 @@ export class Client {
|
|
|
1295
1311
|
return [null, isAxiosError(err) ? err : null];
|
|
1296
1312
|
}
|
|
1297
1313
|
}
|
|
1314
|
+
async fetchUserDeviceListOnce(userID) {
|
|
1315
|
+
if (this.isManualCloseInFlight()) {
|
|
1316
|
+
return [];
|
|
1317
|
+
}
|
|
1318
|
+
const res = await this.http.get(this.getHost() + "/user/" + userID + "/devices");
|
|
1319
|
+
const devices = decodeAxios(DeviceArrayCodec, res.data);
|
|
1320
|
+
for (const device of devices) {
|
|
1321
|
+
this.deviceRecords[device.deviceID] = device;
|
|
1322
|
+
}
|
|
1323
|
+
return devices;
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* DM / forward paths need the peer’s (or self) device rows under load: bounded
|
|
1327
|
+
* retries with exponential backoff (same shape as session pubkey hydration).
|
|
1328
|
+
*/
|
|
1329
|
+
async fetchUserDeviceListWithBackoff(userID, label) {
|
|
1330
|
+
const base = label === "own"
|
|
1331
|
+
? "Couldn't get own devices"
|
|
1332
|
+
: "Couldn't get device list";
|
|
1333
|
+
let lastErr;
|
|
1334
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
1335
|
+
if (this.isManualCloseInFlight()) {
|
|
1336
|
+
return [];
|
|
1337
|
+
}
|
|
1338
|
+
if (attempt > 0) {
|
|
1339
|
+
const delayMs = 100 * 2 ** (attempt - 1);
|
|
1340
|
+
// Chunk the delay so close() can finish before we retry HTTP.
|
|
1341
|
+
const chunkMs = 10;
|
|
1342
|
+
for (let elapsed = 0; elapsed < delayMs; elapsed += chunkMs) {
|
|
1343
|
+
if (this.isManualCloseInFlight()) {
|
|
1344
|
+
return [];
|
|
1345
|
+
}
|
|
1346
|
+
await sleep(Math.min(chunkMs, delayMs - elapsed));
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
try {
|
|
1350
|
+
return await this.fetchUserDeviceListOnce(userID);
|
|
1351
|
+
}
|
|
1352
|
+
catch (err) {
|
|
1353
|
+
lastErr = err;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
|
|
1357
|
+
}
|
|
1298
1358
|
async forward(message) {
|
|
1299
1359
|
if (this.isManualCloseInFlight()) {
|
|
1300
1360
|
return;
|
|
@@ -1363,6 +1423,23 @@ export class Client {
|
|
|
1363
1423
|
return null;
|
|
1364
1424
|
}
|
|
1365
1425
|
}
|
|
1426
|
+
async getDeviceRegistrationRequest(requestID) {
|
|
1427
|
+
try {
|
|
1428
|
+
const response = await this.http.get(this.prefixes.HTTP +
|
|
1429
|
+
this.host +
|
|
1430
|
+
"/user/" +
|
|
1431
|
+
this.getUser().userID +
|
|
1432
|
+
"/devices/requests/" +
|
|
1433
|
+
requestID);
|
|
1434
|
+
return decodeAxios(PendingDeviceRequestCodec, response.data);
|
|
1435
|
+
}
|
|
1436
|
+
catch (err) {
|
|
1437
|
+
if (isAxiosError(err) && err.response?.status === 404) {
|
|
1438
|
+
return null;
|
|
1439
|
+
}
|
|
1440
|
+
throw err;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1366
1443
|
/* Retrieves the current list of users you have sessions with. */
|
|
1367
1444
|
async getFamiliars() {
|
|
1368
1445
|
const sessions = await this.database.getAllSessions();
|
|
@@ -1415,8 +1492,8 @@ export class Client {
|
|
|
1415
1492
|
}
|
|
1416
1493
|
})();
|
|
1417
1494
|
debugLibvexDm("getMail: inbox", {
|
|
1418
|
-
deviceID: did,
|
|
1419
1495
|
count: String(inbox.length),
|
|
1496
|
+
deviceID: did,
|
|
1420
1497
|
});
|
|
1421
1498
|
}
|
|
1422
1499
|
for (const mailDetails of inbox) {
|
|
@@ -1425,8 +1502,8 @@ export class Client {
|
|
|
1425
1502
|
if (libvexDebugDmEnabled()) {
|
|
1426
1503
|
debugLibvexDm("getMail: readMail one", {
|
|
1427
1504
|
mailID: mailBody.mailID,
|
|
1428
|
-
type: String(mailBody.mailType),
|
|
1429
1505
|
recipient: mailBody.recipient,
|
|
1506
|
+
type: String(mailBody.mailType),
|
|
1430
1507
|
});
|
|
1431
1508
|
}
|
|
1432
1509
|
await this.readMail(mailHeader, mailBody, timestamp);
|
|
@@ -1526,19 +1603,6 @@ export class Client {
|
|
|
1526
1603
|
}
|
|
1527
1604
|
return this.user;
|
|
1528
1605
|
}
|
|
1529
|
-
deviceListFailureDetail(err) {
|
|
1530
|
-
if (!isAxiosError(err)) {
|
|
1531
|
-
return "";
|
|
1532
|
-
}
|
|
1533
|
-
const st = err.response?.status;
|
|
1534
|
-
if (typeof st === "number") {
|
|
1535
|
-
return ` (HTTP ${String(st)})`;
|
|
1536
|
-
}
|
|
1537
|
-
if (err.code !== undefined) {
|
|
1538
|
-
return ` (${err.code})`;
|
|
1539
|
-
}
|
|
1540
|
-
return "";
|
|
1541
|
-
}
|
|
1542
1606
|
/**
|
|
1543
1607
|
* Single GET for `/user/:id/devices`. On failure returns `null` (swallows errors)
|
|
1544
1608
|
* — callers that need reliability should use `fetchUserDeviceListWithBackoff`.
|
|
@@ -1553,56 +1617,19 @@ export class Client {
|
|
|
1553
1617
|
return null;
|
|
1554
1618
|
}
|
|
1555
1619
|
}
|
|
1556
|
-
async fetchUserDeviceListOnce(userID) {
|
|
1557
|
-
if (this.isManualCloseInFlight()) {
|
|
1558
|
-
return [];
|
|
1559
|
-
}
|
|
1560
|
-
const res = await this.http.get(this.getHost() + "/user/" + userID + "/devices");
|
|
1561
|
-
const devices = decodeAxios(DeviceArrayCodec, res.data);
|
|
1562
|
-
for (const device of devices) {
|
|
1563
|
-
this.deviceRecords[device.deviceID] = device;
|
|
1564
|
-
}
|
|
1565
|
-
return devices;
|
|
1566
|
-
}
|
|
1567
|
-
/**
|
|
1568
|
-
* DM / forward paths need the peer’s (or self) device rows under load: bounded
|
|
1569
|
-
* retries with exponential backoff (same shape as session pubkey hydration).
|
|
1570
|
-
*/
|
|
1571
|
-
async fetchUserDeviceListWithBackoff(userID, label) {
|
|
1572
|
-
const base = label === "own"
|
|
1573
|
-
? "Couldn't get own devices"
|
|
1574
|
-
: "Couldn't get device list";
|
|
1575
|
-
let lastErr;
|
|
1576
|
-
for (let attempt = 0; attempt < 5; attempt++) {
|
|
1577
|
-
if (this.isManualCloseInFlight()) {
|
|
1578
|
-
return [];
|
|
1579
|
-
}
|
|
1580
|
-
if (attempt > 0) {
|
|
1581
|
-
const delayMs = 100 * 2 ** (attempt - 1);
|
|
1582
|
-
// Chunk the delay so close() can finish before we retry HTTP.
|
|
1583
|
-
const chunkMs = 10;
|
|
1584
|
-
for (let elapsed = 0; elapsed < delayMs; elapsed += chunkMs) {
|
|
1585
|
-
if (this.isManualCloseInFlight()) {
|
|
1586
|
-
return [];
|
|
1587
|
-
}
|
|
1588
|
-
await sleep(Math.min(chunkMs, delayMs - elapsed));
|
|
1589
|
-
}
|
|
1590
|
-
}
|
|
1591
|
-
try {
|
|
1592
|
-
return await this.fetchUserDeviceListOnce(userID);
|
|
1593
|
-
}
|
|
1594
|
-
catch (err) {
|
|
1595
|
-
lastErr = err;
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
|
|
1599
|
-
}
|
|
1600
1620
|
async getUserList(channelID) {
|
|
1601
1621
|
const res = await this.http.post(this.getHost() + "/userList/" + channelID);
|
|
1602
1622
|
return decodeAxios(UserArrayCodec, res.data);
|
|
1603
1623
|
}
|
|
1604
1624
|
async handleNotify(msg) {
|
|
1605
1625
|
switch (msg.event) {
|
|
1626
|
+
case "deviceRequest": {
|
|
1627
|
+
const parsed = deviceRequestNotifyData.safeParse(msg.data);
|
|
1628
|
+
if (parsed.success) {
|
|
1629
|
+
this.emitter.emit("deviceRequest", parsed.data);
|
|
1630
|
+
}
|
|
1631
|
+
break;
|
|
1632
|
+
}
|
|
1606
1633
|
case "mail":
|
|
1607
1634
|
await this.getMail();
|
|
1608
1635
|
this.fetchingMail = false;
|
|
@@ -1617,24 +1644,6 @@ export class Client {
|
|
|
1617
1644
|
break;
|
|
1618
1645
|
}
|
|
1619
1646
|
}
|
|
1620
|
-
/**
|
|
1621
|
-
* Pipeline for decrypted messages — registered in `init`. After `close()` sets
|
|
1622
|
-
* `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
|
|
1623
|
-
* race HTTP teardown (we avoid `off()` here — it can interact badly with emit).
|
|
1624
|
-
*/
|
|
1625
|
-
onInternalMessage = (message) => {
|
|
1626
|
-
if (this.isManualCloseInFlight()) {
|
|
1627
|
-
return;
|
|
1628
|
-
}
|
|
1629
|
-
if (message.direction === "outgoing" && !message.forward) {
|
|
1630
|
-
void this.forward(message);
|
|
1631
|
-
}
|
|
1632
|
-
if (message.direction === "incoming" &&
|
|
1633
|
-
message.recipient === message.sender) {
|
|
1634
|
-
return;
|
|
1635
|
-
}
|
|
1636
|
-
void this.database.saveMessage(message);
|
|
1637
|
-
};
|
|
1638
1647
|
/**
|
|
1639
1648
|
* Initializes the keyring. This must be called before anything else.
|
|
1640
1649
|
*/
|
|
@@ -1722,6 +1731,14 @@ export class Client {
|
|
|
1722
1731
|
throw new Error("Error initiating websocket connection " + String(err));
|
|
1723
1732
|
}
|
|
1724
1733
|
}
|
|
1734
|
+
/**
|
|
1735
|
+
* Fresh read of the `manuallyClosing` flag for async loops — direct property checks
|
|
1736
|
+
* after `await` are flagged as always-false by control-flow analysis even though
|
|
1737
|
+
* `close()` can run concurrently.
|
|
1738
|
+
*/
|
|
1739
|
+
isManualCloseInFlight() {
|
|
1740
|
+
return this.manuallyClosing;
|
|
1741
|
+
}
|
|
1725
1742
|
async kickUser(userID, serverID) {
|
|
1726
1743
|
const permissionList = await this.fetchPermissionList(serverID);
|
|
1727
1744
|
for (const permission of permissionList) {
|
|
@@ -1740,6 +1757,14 @@ export class Client {
|
|
|
1740
1757
|
}
|
|
1741
1758
|
}
|
|
1742
1759
|
}
|
|
1760
|
+
async listDeviceRegistrationRequests() {
|
|
1761
|
+
const response = await this.http.get(this.prefixes.HTTP +
|
|
1762
|
+
this.host +
|
|
1763
|
+
"/user/" +
|
|
1764
|
+
this.getUser().userID +
|
|
1765
|
+
"/devices/requests");
|
|
1766
|
+
return decodeAxios(PendingDeviceRequestArrayCodec, response.data);
|
|
1767
|
+
}
|
|
1743
1768
|
async markSessionVerified(sessionID) {
|
|
1744
1769
|
return this.database.markSessionVerified(sessionID);
|
|
1745
1770
|
}
|
|
@@ -1760,6 +1785,24 @@ export class Client {
|
|
|
1760
1785
|
}
|
|
1761
1786
|
this.xKeyRing.ephemeralKeys = await xBoxKeyPairAsync();
|
|
1762
1787
|
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Pipeline for decrypted messages — registered in `init`. After `close()` sets
|
|
1790
|
+
* `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
|
|
1791
|
+
* race HTTP teardown (we avoid `off()` here — it can interact badly with emit).
|
|
1792
|
+
*/
|
|
1793
|
+
onInternalMessage = (message) => {
|
|
1794
|
+
if (this.isManualCloseInFlight()) {
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
if (message.direction === "outgoing" && !message.forward) {
|
|
1798
|
+
void this.forward(message);
|
|
1799
|
+
}
|
|
1800
|
+
if (message.direction === "incoming" &&
|
|
1801
|
+
message.recipient === message.sender) {
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
void this.database.saveMessage(message);
|
|
1805
|
+
};
|
|
1763
1806
|
ping() {
|
|
1764
1807
|
if (!this.isAlive) {
|
|
1765
1808
|
}
|
|
@@ -1880,7 +1923,7 @@ export class Client {
|
|
|
1880
1923
|
const deviceEntry = await this.getDeviceByID(mail.sender);
|
|
1881
1924
|
const [user, _err] = await this.fetchUser(mail.authorID);
|
|
1882
1925
|
if (deviceEntry && user) {
|
|
1883
|
-
void this.createSession(deviceEntry, user,
|
|
1926
|
+
void this.createSession(deviceEntry, user, new Uint8Array(), mail.group, uuid.v4(), false, true);
|
|
1884
1927
|
}
|
|
1885
1928
|
};
|
|
1886
1929
|
switch (mail.mailType) {
|
|
@@ -1901,8 +1944,8 @@ export class Client {
|
|
|
1901
1944
|
try {
|
|
1902
1945
|
debugLibvexDm("readMail initial: abort (otk index mismatch)", {
|
|
1903
1946
|
mailID: mail.mailID,
|
|
1904
|
-
preKeyIndex: String(preKeyIndex),
|
|
1905
1947
|
otkIndex: String(otk?.index ?? "null"),
|
|
1948
|
+
preKeyIndex: String(preKeyIndex),
|
|
1906
1949
|
thisDevice: this.getDevice().deviceID,
|
|
1907
1950
|
});
|
|
1908
1951
|
}
|
|
@@ -1929,8 +1972,8 @@ export class Client {
|
|
|
1929
1972
|
if (libvexDebugDmEnabled()) {
|
|
1930
1973
|
try {
|
|
1931
1974
|
debugLibvexDm("readMail initial: abort (IK_A null, Ed→X25519?)", {
|
|
1932
|
-
mailID: mail.mailID,
|
|
1933
1975
|
fips: String(fipsRead),
|
|
1976
|
+
mailID: mail.mailID,
|
|
1934
1977
|
thisDevice: this.getDevice().deviceID,
|
|
1935
1978
|
});
|
|
1936
1979
|
}
|
|
@@ -2024,9 +2067,9 @@ export class Client {
|
|
|
2024
2067
|
try {
|
|
2025
2068
|
debugLibvexDm("readMail initial: ok (emit message)", {
|
|
2026
2069
|
mailID: mail.mailID,
|
|
2070
|
+
plaintextLen: String(plaintext.length),
|
|
2027
2071
|
preKeyIndex: String(preKeyIndex),
|
|
2028
2072
|
thisDevice: this.getDevice().deviceID,
|
|
2029
|
-
plaintextLen: String(plaintext.length),
|
|
2030
2073
|
});
|
|
2031
2074
|
}
|
|
2032
2075
|
catch {
|
|
@@ -2211,7 +2254,16 @@ export class Client {
|
|
|
2211
2254
|
"/user/" +
|
|
2212
2255
|
userDetails.userID +
|
|
2213
2256
|
"/devices", msgpack.encode(devMsg), { headers: { "Content-Type": "application/msgpack" } });
|
|
2214
|
-
return decodeAxios(
|
|
2257
|
+
return decodeAxios(DeviceRegistrationResultCodec, res.data);
|
|
2258
|
+
}
|
|
2259
|
+
async rejectDeviceRequest(requestID) {
|
|
2260
|
+
await this.http.post(this.prefixes.HTTP +
|
|
2261
|
+
this.host +
|
|
2262
|
+
"/user/" +
|
|
2263
|
+
this.getUser().userID +
|
|
2264
|
+
"/devices/requests/" +
|
|
2265
|
+
requestID +
|
|
2266
|
+
"/reject");
|
|
2215
2267
|
}
|
|
2216
2268
|
async respond(msg) {
|
|
2217
2269
|
const response = {
|
|
@@ -2282,9 +2334,13 @@ export class Client {
|
|
|
2282
2334
|
await this.database.purgeKeyData();
|
|
2283
2335
|
await this.populateKeyRing();
|
|
2284
2336
|
const newDevice = await this.registerDevice();
|
|
2285
|
-
if (newDevice) {
|
|
2337
|
+
if (newDevice && "deviceID" in newDevice) {
|
|
2286
2338
|
device = newDevice;
|
|
2287
2339
|
}
|
|
2340
|
+
else if (newDevice && "status" in newDevice) {
|
|
2341
|
+
throw new Error("Device registration requires approval from an existing device. requestID=" +
|
|
2342
|
+
newDevice.requestID);
|
|
2343
|
+
}
|
|
2288
2344
|
else {
|
|
2289
2345
|
throw new Error("Error registering device.");
|
|
2290
2346
|
}
|
|
@@ -2295,6 +2351,25 @@ export class Client {
|
|
|
2295
2351
|
}
|
|
2296
2352
|
return device;
|
|
2297
2353
|
}
|
|
2354
|
+
/**
|
|
2355
|
+
* `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
|
|
2356
|
+
* active profile. When several {@link Client} instances use different
|
|
2357
|
+
* `cryptoProfile` values, scope the global to this instance for the duration
|
|
2358
|
+
* of that crypto work.
|
|
2359
|
+
*/
|
|
2360
|
+
async runWithThisCryptoProfile(fn) {
|
|
2361
|
+
const prev = getCryptoProfile();
|
|
2362
|
+
if (prev === this.cryptoProfile) {
|
|
2363
|
+
return await fn();
|
|
2364
|
+
}
|
|
2365
|
+
setCryptoProfile(this.cryptoProfile);
|
|
2366
|
+
try {
|
|
2367
|
+
return await fn();
|
|
2368
|
+
}
|
|
2369
|
+
finally {
|
|
2370
|
+
setCryptoProfile(prev);
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2298
2373
|
/* header is 32 bytes and is either empty
|
|
2299
2374
|
or contains an HMAC of the message with
|
|
2300
2375
|
a derived SK */
|
|
@@ -2344,9 +2419,9 @@ export class Client {
|
|
|
2344
2419
|
if (!session || retry) {
|
|
2345
2420
|
if (libvexDebugDmEnabled()) {
|
|
2346
2421
|
debugLibvexDm("sendMail: createSession path", {
|
|
2422
|
+
hasSession: String(!!session),
|
|
2347
2423
|
peerDevice: device.deviceID,
|
|
2348
2424
|
retry: String(retry),
|
|
2349
|
-
hasSession: String(!!session),
|
|
2350
2425
|
});
|
|
2351
2426
|
}
|
|
2352
2427
|
await this.createSession(device, user, msg, group, mailID, forward, false);
|
|
@@ -2466,11 +2541,11 @@ export class Client {
|
|
|
2466
2541
|
const deviceList = [...deviceListRaw].sort((a, b) => a.deviceID.localeCompare(b.deviceID, "en"));
|
|
2467
2542
|
if (libvexDebugDmEnabled()) {
|
|
2468
2543
|
debugLibvexDm("sendMessage: peer device list (merged, sorted)", {
|
|
2469
|
-
userID,
|
|
2470
2544
|
nAfterBackoff: String(afterBackoff.length),
|
|
2471
2545
|
nMerged: String(deviceListRaw.length),
|
|
2472
2546
|
nSorted: String(deviceList.length),
|
|
2473
2547
|
ourDevice: this.getDevice().deviceID,
|
|
2548
|
+
userID,
|
|
2474
2549
|
});
|
|
2475
2550
|
for (const [i, d] of deviceList.entries()) {
|
|
2476
2551
|
debugLibvexDm(`sendMessage: device[${String(i)}]`, {
|
|
@@ -2480,16 +2555,18 @@ export class Client {
|
|
|
2480
2555
|
}
|
|
2481
2556
|
let lastErr;
|
|
2482
2557
|
let failCount = 0;
|
|
2558
|
+
// One logical DM fan-outs to multiple recipient devices. Reuse a
|
|
2559
|
+
// single mailID so local/UI dedupe treats it as one message.
|
|
2560
|
+
const messageMailID = uuid.v4();
|
|
2483
2561
|
for (const device of deviceList) {
|
|
2484
|
-
const mailID = uuid.v4();
|
|
2485
2562
|
try {
|
|
2486
2563
|
if (libvexDebugDmEnabled()) {
|
|
2487
2564
|
debugLibvexDm("sendMessage: sendMail start", {
|
|
2565
|
+
mailID: messageMailID,
|
|
2488
2566
|
recipientDevice: device.deviceID,
|
|
2489
|
-
mailID,
|
|
2490
2567
|
});
|
|
2491
2568
|
}
|
|
2492
|
-
await this.sendMail(device, userEntry, XUtils.decodeUTF8(message), null,
|
|
2569
|
+
await this.sendMail(device, userEntry, XUtils.decodeUTF8(message), null, messageMailID, false);
|
|
2493
2570
|
if (libvexDebugDmEnabled()) {
|
|
2494
2571
|
debugLibvexDm("sendMessage: sendMail ok", {
|
|
2495
2572
|
recipientDevice: device.deviceID,
|