@thezelijah/majik-message 1.1.1 → 1.1.3
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/dist/core/crypto/constants.d.ts +1 -0
- package/dist/core/crypto/constants.js +1 -0
- package/dist/core/crypto/crypto-provider.d.ts +1 -0
- package/dist/core/crypto/crypto-provider.js +4 -1
- package/dist/core/crypto/keystore.d.ts +2 -0
- package/dist/core/crypto/keystore.js +16 -3
- package/dist/core/database/thread/enums.d.ts +7 -0
- package/dist/core/database/thread/enums.js +6 -0
- package/dist/core/database/thread/mail/majik-message-mail.d.ts +177 -0
- package/dist/core/database/thread/mail/majik-message-mail.js +704 -0
- package/dist/core/database/thread/majik-message-thread.d.ts +166 -0
- package/dist/core/database/thread/majik-message-thread.js +637 -0
- package/dist/core/types.d.ts +2 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/majik-message.js +23 -21
- package/package.json +6 -1
- package/dist/core/database/system/majik-user/enums.d.ts +0 -44
- package/dist/core/database/system/majik-user/enums.js +0 -40
- package/dist/core/database/system/majik-user/majik-user.d.ts +0 -261
- package/dist/core/database/system/majik-user/majik-user.js +0 -839
- package/dist/core/database/system/majik-user/types.d.ts +0 -186
- package/dist/core/database/system/majik-user/types.js +0 -1
- package/dist/core/database/system/majik-user/utils.d.ts +0 -32
- package/dist/core/database/system/majik-user/utils.js +0 -110
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid";
|
|
2
|
+
import { sha256 } from "../../../crypto/crypto-provider";
|
|
3
|
+
import { ThreadStatus } from "../enums";
|
|
4
|
+
// ==================== Custom Errors ====================
|
|
5
|
+
export class MajikMailError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(message, code) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.name = "MajikMailError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class MailValidationError extends MajikMailError {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message, "MAIL_VALIDATION_ERROR");
|
|
16
|
+
this.name = "MailValidationError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class MailOperationError extends MajikMailError {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message, "MAIL_OPERATION_ERROR");
|
|
22
|
+
this.name = "MailOperationError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export class HashIntegrityError extends MajikMailError {
|
|
26
|
+
constructor(message) {
|
|
27
|
+
super(message, "HASH_INTEGRITY_ERROR");
|
|
28
|
+
this.name = "HashIntegrityError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// ==================== Main Class ====================
|
|
32
|
+
export class MajikMessageMail {
|
|
33
|
+
_id;
|
|
34
|
+
_threadID;
|
|
35
|
+
_account;
|
|
36
|
+
_message; // Compressed message
|
|
37
|
+
_sender;
|
|
38
|
+
_recipients;
|
|
39
|
+
_timestamp;
|
|
40
|
+
_metadata;
|
|
41
|
+
_hash; // Current item hash
|
|
42
|
+
_p_hash; // Previous hash (blockchain link)
|
|
43
|
+
_previousMailID;
|
|
44
|
+
_readBy;
|
|
45
|
+
// Maximum allowed length for the raw message
|
|
46
|
+
static MAX_MESSAGE_LENGTH = 100000;
|
|
47
|
+
// ==================== Private Constructor ====================
|
|
48
|
+
constructor(id, threadID, account, message, sender, recipients, timestamp, metadata, hash, p_hash, previousMailID, readBy = []) {
|
|
49
|
+
this._id = id;
|
|
50
|
+
this._threadID = threadID;
|
|
51
|
+
this._account = account;
|
|
52
|
+
this._message = message;
|
|
53
|
+
this._sender = sender;
|
|
54
|
+
this._recipients = [...recipients];
|
|
55
|
+
this._timestamp = timestamp;
|
|
56
|
+
this._metadata = { ...metadata };
|
|
57
|
+
this._hash = hash;
|
|
58
|
+
this._p_hash = p_hash;
|
|
59
|
+
this._previousMailID = previousMailID;
|
|
60
|
+
this._readBy = [...readBy];
|
|
61
|
+
// Validate on construction
|
|
62
|
+
this.validate();
|
|
63
|
+
}
|
|
64
|
+
// ==================== Getters ====================
|
|
65
|
+
get id() {
|
|
66
|
+
return this._id;
|
|
67
|
+
}
|
|
68
|
+
get threadID() {
|
|
69
|
+
return this._threadID;
|
|
70
|
+
}
|
|
71
|
+
get account() {
|
|
72
|
+
return this._account;
|
|
73
|
+
}
|
|
74
|
+
get sender() {
|
|
75
|
+
return this._sender;
|
|
76
|
+
}
|
|
77
|
+
get recipients() {
|
|
78
|
+
return [...this._recipients];
|
|
79
|
+
}
|
|
80
|
+
get timestamp() {
|
|
81
|
+
return new Date(this._timestamp);
|
|
82
|
+
}
|
|
83
|
+
get metadata() {
|
|
84
|
+
return { ...this._metadata };
|
|
85
|
+
}
|
|
86
|
+
get hash() {
|
|
87
|
+
return this._hash;
|
|
88
|
+
}
|
|
89
|
+
get p_hash() {
|
|
90
|
+
return this._p_hash;
|
|
91
|
+
}
|
|
92
|
+
get previousMailID() {
|
|
93
|
+
return this._previousMailID;
|
|
94
|
+
}
|
|
95
|
+
get readBy() {
|
|
96
|
+
return [...this._readBy];
|
|
97
|
+
}
|
|
98
|
+
get message() {
|
|
99
|
+
return this._message;
|
|
100
|
+
}
|
|
101
|
+
// ==================== Static Create Method ====================
|
|
102
|
+
/**
|
|
103
|
+
* Creates the first mail item in a thread.
|
|
104
|
+
* Uses the thread's hash as the p_hash since this is the first item.
|
|
105
|
+
*
|
|
106
|
+
* @param thread - The MajikMessageThread this mail belongs to
|
|
107
|
+
* @param identity - The sender's MajikMessageIdentity
|
|
108
|
+
* @param message - Plain text message (encrypted)
|
|
109
|
+
* @param recipients - Array of recipient public keys (excluding sender)
|
|
110
|
+
* @param metadata - Optional mail metadata
|
|
111
|
+
* @returns Promise resolving to new MajikMessageMail instance
|
|
112
|
+
* @throws Error if validation fails or thread is closed
|
|
113
|
+
*/
|
|
114
|
+
static async create(thread, identity, message, recipients, metadata = {}) {
|
|
115
|
+
try {
|
|
116
|
+
// Validate thread
|
|
117
|
+
if (!thread) {
|
|
118
|
+
throw new MailValidationError("Thread is required");
|
|
119
|
+
}
|
|
120
|
+
// Validate thread is not closed or marked for deletion
|
|
121
|
+
if (thread.status === ThreadStatus.CLOSED) {
|
|
122
|
+
throw new MailOperationError("Cannot create mail in a closed thread");
|
|
123
|
+
}
|
|
124
|
+
if (thread.status === ThreadStatus.MARKED_FOR_DELETION) {
|
|
125
|
+
throw new MailOperationError("Cannot create mail in a thread marked for deletion");
|
|
126
|
+
}
|
|
127
|
+
// Validate thread integrity
|
|
128
|
+
thread.validate();
|
|
129
|
+
// Validate identity
|
|
130
|
+
if (!identity) {
|
|
131
|
+
throw new MailValidationError("Identity is required");
|
|
132
|
+
}
|
|
133
|
+
if (!identity.validateIntegrity()) {
|
|
134
|
+
throw new MailValidationError("Identity integrity check failed");
|
|
135
|
+
}
|
|
136
|
+
if (identity.isRestricted()) {
|
|
137
|
+
throw new MailOperationError("This account is restricted and cannot send mail");
|
|
138
|
+
}
|
|
139
|
+
const accountID = identity.id;
|
|
140
|
+
const senderPublicKey = identity.publicKey;
|
|
141
|
+
// Validate sender is a participant in the thread
|
|
142
|
+
if (!thread.isParticipant(senderPublicKey)) {
|
|
143
|
+
throw new MailOperationError("Sender must be a participant in the thread");
|
|
144
|
+
}
|
|
145
|
+
// Validate message
|
|
146
|
+
if (!message || typeof message !== "string" || message.trim() === "") {
|
|
147
|
+
throw new MailValidationError("Message must be a non-empty string");
|
|
148
|
+
}
|
|
149
|
+
// Validate raw message length
|
|
150
|
+
this.validateRawMessageLength(message);
|
|
151
|
+
// Validate recipients
|
|
152
|
+
if (!Array.isArray(recipients) || recipients.length === 0) {
|
|
153
|
+
throw new MailValidationError("Recipients must be a non-empty array");
|
|
154
|
+
}
|
|
155
|
+
// Validate recipients are unique
|
|
156
|
+
const uniqueRecipients = new Set(recipients);
|
|
157
|
+
if (uniqueRecipients.size !== recipients.length) {
|
|
158
|
+
throw new MailValidationError("Duplicate recipients found");
|
|
159
|
+
}
|
|
160
|
+
// Validate sender is not in recipients
|
|
161
|
+
if (recipients.includes(senderPublicKey)) {
|
|
162
|
+
throw new MailValidationError("Sender cannot be included in recipients list");
|
|
163
|
+
}
|
|
164
|
+
// Validate all recipients are participants in the thread
|
|
165
|
+
for (const recipient of recipients) {
|
|
166
|
+
if (!thread.isParticipant(recipient)) {
|
|
167
|
+
throw new MailValidationError(`Recipient ${recipient} is not a participant in the thread`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Normalize recipients (sort for consistency)
|
|
171
|
+
const normalizedRecipients = [...recipients].sort();
|
|
172
|
+
// Generate ID and timestamp
|
|
173
|
+
const id = uuidv4();
|
|
174
|
+
const timestamp = new Date();
|
|
175
|
+
// Generate hash for this mail item
|
|
176
|
+
const hash = this.generateHash(id, message.trim(), senderPublicKey, normalizedRecipients, timestamp);
|
|
177
|
+
// For the first item, p_hash is the thread's hash
|
|
178
|
+
const p_hash = this.generatePHash(hash, thread.hash);
|
|
179
|
+
// Mark as reply metadata
|
|
180
|
+
const finalMetadata = {
|
|
181
|
+
...metadata,
|
|
182
|
+
isReply: false,
|
|
183
|
+
};
|
|
184
|
+
return new MajikMessageMail(id, thread.id, accountID, message.trim(), senderPublicKey, normalizedRecipients, timestamp, finalMetadata, hash, p_hash, undefined, // No previous mail ID for first item
|
|
185
|
+
[]);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (error instanceof MajikMailError) {
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
throw new MailOperationError(`Failed to create mail: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// ==================== Static Reply Method ====================
|
|
195
|
+
/**
|
|
196
|
+
* Creates a reply to an existing mail item in the thread.
|
|
197
|
+
* Uses the previous mail's hash as part of the p_hash.
|
|
198
|
+
*
|
|
199
|
+
* @param thread - The MajikMessageThread this mail belongs to
|
|
200
|
+
* @param previousMail - The mail being replied to
|
|
201
|
+
* @param identity - The sender's MajikMessageIdentity
|
|
202
|
+
* @param message - Plain text message (encrypted)
|
|
203
|
+
* @param recipients - Array of recipient public keys (excluding sender)
|
|
204
|
+
* @param metadata - Optional mail metadata
|
|
205
|
+
* @returns Promise resolving to new MajikMessageMail instance
|
|
206
|
+
* @throws Error if validation fails or thread is closed
|
|
207
|
+
*/
|
|
208
|
+
static async reply(thread, previousMail, identity, message, recipients, metadata = {}) {
|
|
209
|
+
try {
|
|
210
|
+
// Validate thread
|
|
211
|
+
if (!thread) {
|
|
212
|
+
throw new MailValidationError("Thread is required");
|
|
213
|
+
}
|
|
214
|
+
// Validate thread is not closed or marked for deletion
|
|
215
|
+
if (thread.status === ThreadStatus.CLOSED) {
|
|
216
|
+
throw new MailOperationError("Cannot reply in a closed thread");
|
|
217
|
+
}
|
|
218
|
+
if (thread.status === ThreadStatus.MARKED_FOR_DELETION) {
|
|
219
|
+
throw new MailOperationError("Cannot reply in a thread marked for deletion");
|
|
220
|
+
}
|
|
221
|
+
// Validate thread integrity
|
|
222
|
+
thread.validate();
|
|
223
|
+
// Validate previous mail
|
|
224
|
+
if (!previousMail) {
|
|
225
|
+
throw new MailValidationError("Previous mail is required for reply");
|
|
226
|
+
}
|
|
227
|
+
// Validate previous mail integrity
|
|
228
|
+
previousMail.validate();
|
|
229
|
+
// Verify previous mail belongs to the same thread
|
|
230
|
+
if (previousMail.threadID !== thread.id) {
|
|
231
|
+
throw new MailValidationError("Previous mail does not belong to the specified thread");
|
|
232
|
+
}
|
|
233
|
+
// Validate identity
|
|
234
|
+
if (!identity) {
|
|
235
|
+
throw new MailValidationError("Identity is required");
|
|
236
|
+
}
|
|
237
|
+
if (!identity.validateIntegrity()) {
|
|
238
|
+
throw new MailValidationError("Identity integrity check failed");
|
|
239
|
+
}
|
|
240
|
+
if (identity.isRestricted()) {
|
|
241
|
+
throw new MailOperationError("This account is restricted and cannot send mail");
|
|
242
|
+
}
|
|
243
|
+
const accountID = identity.id;
|
|
244
|
+
const senderPublicKey = identity.publicKey;
|
|
245
|
+
// Validate sender is a participant in the thread
|
|
246
|
+
if (!thread.isParticipant(senderPublicKey)) {
|
|
247
|
+
throw new MailOperationError("Sender must be a participant in the thread");
|
|
248
|
+
}
|
|
249
|
+
// Validate message
|
|
250
|
+
if (!message || typeof message !== "string" || message.trim() === "") {
|
|
251
|
+
throw new MailValidationError("Message must be a non-empty string");
|
|
252
|
+
}
|
|
253
|
+
// Validate raw message length
|
|
254
|
+
this.validateRawMessageLength(message);
|
|
255
|
+
// Validate recipients
|
|
256
|
+
if (!Array.isArray(recipients) || recipients.length === 0) {
|
|
257
|
+
throw new MailValidationError("Recipients must be a non-empty array");
|
|
258
|
+
}
|
|
259
|
+
// Validate recipients are unique
|
|
260
|
+
const uniqueRecipients = new Set(recipients);
|
|
261
|
+
if (uniqueRecipients.size !== recipients.length) {
|
|
262
|
+
throw new MailValidationError("Duplicate recipients found");
|
|
263
|
+
}
|
|
264
|
+
// Validate sender is not in recipients
|
|
265
|
+
if (recipients.includes(senderPublicKey)) {
|
|
266
|
+
throw new MailValidationError("Sender cannot be included in recipients list");
|
|
267
|
+
}
|
|
268
|
+
// Validate all recipients are participants in the thread
|
|
269
|
+
for (const recipient of recipients) {
|
|
270
|
+
if (!thread.isParticipant(recipient)) {
|
|
271
|
+
throw new MailValidationError(`Recipient ${recipient} is not a participant in the thread`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Normalize recipients (sort for consistency)
|
|
275
|
+
const normalizedRecipients = [...recipients].sort();
|
|
276
|
+
// Generate ID and timestamp
|
|
277
|
+
const id = uuidv4();
|
|
278
|
+
const timestamp = new Date();
|
|
279
|
+
// Generate hash for this mail item
|
|
280
|
+
const hash = this.generateHash(id, message.trim(), senderPublicKey, normalizedRecipients, timestamp);
|
|
281
|
+
// For replies, p_hash links to previous mail's hash
|
|
282
|
+
const p_hash = this.generatePHash(hash, previousMail.hash);
|
|
283
|
+
// Mark as reply in metadata
|
|
284
|
+
const finalMetadata = {
|
|
285
|
+
...metadata,
|
|
286
|
+
isReply: true,
|
|
287
|
+
};
|
|
288
|
+
return new MajikMessageMail(id, thread.id, accountID, message.trim(), senderPublicKey, normalizedRecipients, timestamp, finalMetadata, hash, p_hash, previousMail.id, []);
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
if (error instanceof MajikMailError) {
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
throw new MailOperationError(`Failed to create reply: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// ==================== Hash Generation Methods ====================
|
|
298
|
+
/**
|
|
299
|
+
* Generates the hash for the current mail item.
|
|
300
|
+
* Format: SHA256(id:message:sender:recipients:timestamp)
|
|
301
|
+
*/
|
|
302
|
+
static generateHash(id, message, sender, recipients, timestamp) {
|
|
303
|
+
const recipientsStr = recipients.join(",");
|
|
304
|
+
const dataString = `${id}:${message}:${sender}:${recipientsStr}:${timestamp.toISOString()}`;
|
|
305
|
+
return sha256(dataString);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Generates the previous hash (blockchain link).
|
|
309
|
+
* Format: SHA256(currentHash:previousHash)
|
|
310
|
+
*/
|
|
311
|
+
static generatePHash(currentHash, previousHash) {
|
|
312
|
+
const dataString = `${currentHash}:${previousHash}`;
|
|
313
|
+
return sha256(dataString);
|
|
314
|
+
}
|
|
315
|
+
// ==================== Validation Methods ====================
|
|
316
|
+
/**
|
|
317
|
+
* Validates the current mail item's integrity.
|
|
318
|
+
* Checks hash and p_hash validity.
|
|
319
|
+
*/
|
|
320
|
+
validate() {
|
|
321
|
+
try {
|
|
322
|
+
// Validate ID
|
|
323
|
+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
324
|
+
if (!uuidRegex.test(this._id)) {
|
|
325
|
+
throw new MailValidationError("Invalid UUID v4 format for id");
|
|
326
|
+
}
|
|
327
|
+
// Validate thread ID
|
|
328
|
+
if (!uuidRegex.test(this._threadID)) {
|
|
329
|
+
throw new MailValidationError("Invalid UUID v4 format for thread_id");
|
|
330
|
+
}
|
|
331
|
+
// Validate account ID
|
|
332
|
+
if (!this._account ||
|
|
333
|
+
typeof this._account !== "string" ||
|
|
334
|
+
this._account.trim().length === 0) {
|
|
335
|
+
throw new MailValidationError("Account ID is required and must be a non-empty string");
|
|
336
|
+
}
|
|
337
|
+
// Validate message
|
|
338
|
+
if (!this._message ||
|
|
339
|
+
typeof this._message !== "string" ||
|
|
340
|
+
this._message.trim().length === 0) {
|
|
341
|
+
throw new MailValidationError("Message is required and must be a non-empty string");
|
|
342
|
+
}
|
|
343
|
+
// Validate sender
|
|
344
|
+
if (!this._sender ||
|
|
345
|
+
typeof this._sender !== "string" ||
|
|
346
|
+
this._sender.trim().length === 0) {
|
|
347
|
+
throw new MailValidationError("Sender is required and must be a non-empty string");
|
|
348
|
+
}
|
|
349
|
+
// Validate recipients
|
|
350
|
+
if (!Array.isArray(this._recipients) || this._recipients.length === 0) {
|
|
351
|
+
throw new MailValidationError("Recipients must be a non-empty array");
|
|
352
|
+
}
|
|
353
|
+
// Validate sender is not in recipients
|
|
354
|
+
if (this._recipients.includes(this._sender)) {
|
|
355
|
+
throw new MailValidationError("Sender cannot be in recipients list");
|
|
356
|
+
}
|
|
357
|
+
// Validate timestamp
|
|
358
|
+
if (!(this._timestamp instanceof Date) ||
|
|
359
|
+
isNaN(this._timestamp.getTime())) {
|
|
360
|
+
throw new MailValidationError("Timestamp must be a valid Date object");
|
|
361
|
+
}
|
|
362
|
+
// Validate hash
|
|
363
|
+
if (!this._hash || typeof this._hash !== "string") {
|
|
364
|
+
throw new MailValidationError("Hash is required and must be a string");
|
|
365
|
+
}
|
|
366
|
+
// Validate p_hash
|
|
367
|
+
if (!this._p_hash || typeof this._p_hash !== "string") {
|
|
368
|
+
throw new MailValidationError("Previous hash (p_hash) is required and must be a string");
|
|
369
|
+
}
|
|
370
|
+
// Verify hash integrity
|
|
371
|
+
const expectedHash = MajikMessageMail.generateHash(this._id, this._message, this._sender, this._recipients, this._timestamp);
|
|
372
|
+
if (this._hash !== expectedHash) {
|
|
373
|
+
throw new HashIntegrityError("Hash mismatch - mail item integrity compromised");
|
|
374
|
+
}
|
|
375
|
+
// Validate read_by
|
|
376
|
+
if (!Array.isArray(this._readBy)) {
|
|
377
|
+
throw new MailValidationError("read_by must be an array");
|
|
378
|
+
}
|
|
379
|
+
// Validate all readers are recipients
|
|
380
|
+
for (const reader of this._readBy) {
|
|
381
|
+
if (!this._recipients.includes(reader)) {
|
|
382
|
+
throw new MailValidationError(`Reader ${reader} is not in recipients list`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// Check for duplicate readers
|
|
386
|
+
const uniqueReaders = new Set(this._readBy);
|
|
387
|
+
if (uniqueReaders.size !== this._readBy.length) {
|
|
388
|
+
throw new MailValidationError("Duplicate readers found in read_by");
|
|
389
|
+
}
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
catch (error) {
|
|
393
|
+
if (error instanceof MajikMailError) {
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
throw new MailValidationError(`Validation failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Validates the p_hash against a previous hash.
|
|
401
|
+
* Used to verify blockchain integrity.
|
|
402
|
+
*
|
|
403
|
+
* @param previousHash - The hash from the previous item (or thread hash for first item)
|
|
404
|
+
* @returns true if p_hash is valid
|
|
405
|
+
*/
|
|
406
|
+
validatePHash(previousHash) {
|
|
407
|
+
try {
|
|
408
|
+
if (!previousHash || typeof previousHash !== "string") {
|
|
409
|
+
throw new MailValidationError("Previous hash must be a non-empty string");
|
|
410
|
+
}
|
|
411
|
+
const expectedPHash = MajikMessageMail.generatePHash(this._hash, previousHash);
|
|
412
|
+
if (this._p_hash !== expectedPHash) {
|
|
413
|
+
throw new HashIntegrityError("Previous hash (p_hash) mismatch - blockchain integrity compromised");
|
|
414
|
+
}
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
if (error instanceof MajikMailError) {
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
throw new MailValidationError(`p_hash validation failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
validateMessage(message) {
|
|
425
|
+
if (!message || typeof message !== "string" || message.trim() === "") {
|
|
426
|
+
throw new Error("Invalid message: must be a non-empty string");
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// ==================== Static Blockchain Validation ====================
|
|
430
|
+
/**
|
|
431
|
+
* Validates an entire chain of mail items in a thread.
|
|
432
|
+
* Verifies both hash and p_hash integrity for all items.
|
|
433
|
+
*
|
|
434
|
+
* @param thread - The thread these mail items belong to
|
|
435
|
+
* @param mailItems - Array of mail items ordered chronologically (oldest first)
|
|
436
|
+
* @returns Validation result with details
|
|
437
|
+
*/
|
|
438
|
+
static validateMailChain(thread, mailItems) {
|
|
439
|
+
const errors = [];
|
|
440
|
+
const tamperedItems = [];
|
|
441
|
+
try {
|
|
442
|
+
// Validate thread
|
|
443
|
+
if (!thread) {
|
|
444
|
+
errors.push("Thread is required");
|
|
445
|
+
return { isValid: false, errors, tamperedItems };
|
|
446
|
+
}
|
|
447
|
+
// Validate thread integrity
|
|
448
|
+
try {
|
|
449
|
+
thread.validate();
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
errors.push(`Thread validation failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
453
|
+
return { isValid: false, errors, tamperedItems };
|
|
454
|
+
}
|
|
455
|
+
// Validate mail items array
|
|
456
|
+
if (!Array.isArray(mailItems)) {
|
|
457
|
+
errors.push("Mail items must be an array");
|
|
458
|
+
return { isValid: false, errors, tamperedItems };
|
|
459
|
+
}
|
|
460
|
+
if (mailItems.length === 0) {
|
|
461
|
+
// Empty chain is valid
|
|
462
|
+
return { isValid: true, errors: [], tamperedItems: [] };
|
|
463
|
+
}
|
|
464
|
+
// Validate each mail item individually first
|
|
465
|
+
for (let i = 0; i < mailItems.length; i++) {
|
|
466
|
+
const mail = mailItems[i];
|
|
467
|
+
try {
|
|
468
|
+
mail.validate();
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
errors.push(`Mail item ${i} (${mail.id}) validation failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
472
|
+
tamperedItems.push(mail.id);
|
|
473
|
+
}
|
|
474
|
+
// Verify mail belongs to the thread
|
|
475
|
+
if (mail.threadID !== thread.id) {
|
|
476
|
+
errors.push(`Mail item ${i} (${mail.id}) does not belong to thread ${thread.id}`);
|
|
477
|
+
tamperedItems.push(mail.id);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// Validate blockchain linkage
|
|
481
|
+
for (let i = 0; i < mailItems.length; i++) {
|
|
482
|
+
const currentMail = mailItems[i];
|
|
483
|
+
let previousHash;
|
|
484
|
+
if (i === 0) {
|
|
485
|
+
// First item should link to thread hash
|
|
486
|
+
previousHash = thread.hash;
|
|
487
|
+
// Verify previousMailID is undefined for first item
|
|
488
|
+
if (currentMail.previousMailID !== undefined) {
|
|
489
|
+
errors.push(`First mail item (${currentMail.id}) should not have a previousMailID`);
|
|
490
|
+
tamperedItems.push(currentMail.id);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
// Subsequent items should link to previous mail's hash
|
|
495
|
+
const previousMail = mailItems[i - 1];
|
|
496
|
+
previousHash = previousMail.hash;
|
|
497
|
+
// Verify previousMailID matches
|
|
498
|
+
if (currentMail.previousMailID !== previousMail.id) {
|
|
499
|
+
errors.push(`Mail item ${i} (${currentMail.id}) previousMailID mismatch. ` +
|
|
500
|
+
`Expected: ${previousMail.id}, Got: ${currentMail.previousMailID}`);
|
|
501
|
+
tamperedItems.push(currentMail.id);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// Validate p_hash linkage
|
|
505
|
+
try {
|
|
506
|
+
currentMail.validatePHash(previousHash);
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
errors.push(`Mail item ${i} (${currentMail.id}) p_hash validation failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
510
|
+
tamperedItems.push(currentMail.id);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const isValid = errors.length === 0 && tamperedItems.length === 0;
|
|
514
|
+
return {
|
|
515
|
+
isValid,
|
|
516
|
+
errors,
|
|
517
|
+
tamperedItems: Array.from(new Set(tamperedItems)), // Remove duplicates
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
errors.push(`Chain validation error: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
522
|
+
return { isValid: false, errors, tamperedItems };
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// ==================== Reader Management ====================
|
|
526
|
+
/**
|
|
527
|
+
* Marks this mail as read by a recipient.
|
|
528
|
+
* @param recipientPublicKey - The public key of the recipient marking as read
|
|
529
|
+
* @returns true if successfully marked, false if already read
|
|
530
|
+
*/
|
|
531
|
+
markAsRead(recipientPublicKey) {
|
|
532
|
+
try {
|
|
533
|
+
if (!recipientPublicKey ||
|
|
534
|
+
typeof recipientPublicKey !== "string" ||
|
|
535
|
+
recipientPublicKey.trim() === "") {
|
|
536
|
+
throw new MailValidationError("Recipient public key must be a non-empty string");
|
|
537
|
+
}
|
|
538
|
+
const trimmedKey = recipientPublicKey.trim();
|
|
539
|
+
// Verify recipient is in recipients list
|
|
540
|
+
if (!this._recipients.includes(trimmedKey)) {
|
|
541
|
+
throw new MailOperationError(`User ${trimmedKey} is not a recipient of this mail`);
|
|
542
|
+
}
|
|
543
|
+
// Check if already read (idempotent)
|
|
544
|
+
if (this._readBy.includes(trimmedKey)) {
|
|
545
|
+
return false; // Already read
|
|
546
|
+
}
|
|
547
|
+
this._readBy.push(trimmedKey);
|
|
548
|
+
return true; // Successfully marked as read
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
if (error instanceof MajikMailError) {
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
554
|
+
throw new MailOperationError(`Failed to mark as read: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Checks if a specific user has read this mail.
|
|
559
|
+
*/
|
|
560
|
+
hasUserRead(recipientPublicKey) {
|
|
561
|
+
if (!recipientPublicKey || typeof recipientPublicKey !== "string") {
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
return this._readBy.includes(recipientPublicKey.trim());
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Checks if all recipients have read this mail.
|
|
568
|
+
*/
|
|
569
|
+
isReadByAll() {
|
|
570
|
+
return (this._readBy.length === this._recipients.length &&
|
|
571
|
+
this._recipients.every((recipient) => this._readBy.includes(recipient)));
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Gets the list of recipients who haven't read this mail yet.
|
|
575
|
+
*/
|
|
576
|
+
getUnreadRecipients() {
|
|
577
|
+
return this._recipients.filter((recipient) => !this._readBy.includes(recipient));
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Gets the read percentage.
|
|
581
|
+
*/
|
|
582
|
+
getReadPercentage() {
|
|
583
|
+
if (this._recipients.length === 0) {
|
|
584
|
+
return 100;
|
|
585
|
+
}
|
|
586
|
+
return (this._readBy.length / this._recipients.length) * 100;
|
|
587
|
+
}
|
|
588
|
+
// ==================== Access Control ====================
|
|
589
|
+
/**
|
|
590
|
+
* Checks if a user can access this mail (is sender or recipient).
|
|
591
|
+
*/
|
|
592
|
+
canUserAccess(userPublicKey) {
|
|
593
|
+
if (!userPublicKey || typeof userPublicKey !== "string") {
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
const trimmedKey = userPublicKey.trim();
|
|
597
|
+
return trimmedKey === this._sender || this._recipients.includes(trimmedKey);
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Checks if a user is the sender of this mail.
|
|
601
|
+
*/
|
|
602
|
+
isSender(userPublicKey) {
|
|
603
|
+
if (!userPublicKey || typeof userPublicKey !== "string") {
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
return userPublicKey.trim() === this._sender;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Checks if a user is a recipient of this mail.
|
|
610
|
+
*/
|
|
611
|
+
isRecipient(userPublicKey) {
|
|
612
|
+
if (!userPublicKey || typeof userPublicKey !== "string") {
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
return this._recipients.includes(userPublicKey.trim());
|
|
616
|
+
}
|
|
617
|
+
// ==================== Metadata Management ====================
|
|
618
|
+
/**
|
|
619
|
+
* Updates the metadata for this mail.
|
|
620
|
+
*/
|
|
621
|
+
updateMetadata(metadata) {
|
|
622
|
+
try {
|
|
623
|
+
this._metadata = {
|
|
624
|
+
...this._metadata,
|
|
625
|
+
...metadata,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
catch (error) {
|
|
629
|
+
throw new MailOperationError(`Failed to update metadata: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
// ==================== Serialization ====================
|
|
633
|
+
toJSON() {
|
|
634
|
+
return {
|
|
635
|
+
id: this._id,
|
|
636
|
+
thread_id: this._threadID,
|
|
637
|
+
account: this._account,
|
|
638
|
+
message: this._message,
|
|
639
|
+
sender: this._sender,
|
|
640
|
+
recipients: [...this._recipients],
|
|
641
|
+
timestamp: this._timestamp.toISOString(),
|
|
642
|
+
metadata: { ...this._metadata },
|
|
643
|
+
hash: this._hash,
|
|
644
|
+
p_hash: this._p_hash,
|
|
645
|
+
previous_mail_id: this._previousMailID,
|
|
646
|
+
read_by: [...this._readBy],
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
static fromJSON(json) {
|
|
650
|
+
try {
|
|
651
|
+
const data = typeof json === "string" ? JSON.parse(json) : json;
|
|
652
|
+
// Validate required fields
|
|
653
|
+
if (!this.isValidJSON(data)) {
|
|
654
|
+
throw new MailValidationError("Invalid JSON: missing required fields or invalid types");
|
|
655
|
+
}
|
|
656
|
+
// Parse timestamp
|
|
657
|
+
const timestamp = new Date(data.timestamp);
|
|
658
|
+
if (isNaN(timestamp.getTime())) {
|
|
659
|
+
throw new MailValidationError("Invalid timestamp in JSON data");
|
|
660
|
+
}
|
|
661
|
+
return new MajikMessageMail(data.id, data.thread_id, data.account, data.message, data.sender, data.recipients, timestamp, data.metadata || {}, data.hash, data.p_hash, data.previous_mail_id, data.read_by || []);
|
|
662
|
+
}
|
|
663
|
+
catch (error) {
|
|
664
|
+
if (error instanceof MajikMailError) {
|
|
665
|
+
throw error;
|
|
666
|
+
}
|
|
667
|
+
throw new MailOperationError(`Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
// ==================== Utility Methods ====================
|
|
671
|
+
toString() {
|
|
672
|
+
return JSON.stringify(this.toJSON(), null, 2);
|
|
673
|
+
}
|
|
674
|
+
clone() {
|
|
675
|
+
return MajikMessageMail.fromJSON(this.toJSON());
|
|
676
|
+
}
|
|
677
|
+
// ==================== Private Validation Methods ====================
|
|
678
|
+
/**
|
|
679
|
+
* Validates raw message length
|
|
680
|
+
*/
|
|
681
|
+
static validateRawMessageLength(message) {
|
|
682
|
+
if (!message || typeof message !== "string" || message.trim() === "") {
|
|
683
|
+
throw new MailValidationError("Message must be a non-empty string");
|
|
684
|
+
}
|
|
685
|
+
if (message.length > this.MAX_MESSAGE_LENGTH) {
|
|
686
|
+
throw new MailValidationError(`Raw message exceeds maximum allowed length of ${this.MAX_MESSAGE_LENGTH} characters. ` +
|
|
687
|
+
`Current length: ${message.length}`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
static isValidJSON(json) {
|
|
691
|
+
return (json &&
|
|
692
|
+
typeof json === "object" &&
|
|
693
|
+
typeof json.id === "string" &&
|
|
694
|
+
typeof json.thread_id === "string" &&
|
|
695
|
+
typeof json.account === "string" &&
|
|
696
|
+
typeof json.message === "string" &&
|
|
697
|
+
typeof json.sender === "string" &&
|
|
698
|
+
Array.isArray(json.recipients) &&
|
|
699
|
+
typeof json.timestamp === "string" &&
|
|
700
|
+
typeof json.hash === "string" &&
|
|
701
|
+
typeof json.p_hash === "string" &&
|
|
702
|
+
Array.isArray(json.read_by));
|
|
703
|
+
}
|
|
704
|
+
}
|