@ahrara/sdk 0.0.0-stage → 0.1.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/index.js ADDED
@@ -0,0 +1,541 @@
1
+ import { existsSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { isAbsolute } from "node:path";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { inspect, promisify } from "node:util";
6
+ import koffi from "koffi";
7
+
8
+ /** Native failure with a stable `code`; messages never include Recovery Keys. */
9
+ export class AhraraError extends Error {
10
+ constructor(code, message) {
11
+ super(message);
12
+ this.name = "AhraraError";
13
+ this.code = code;
14
+ }
15
+ }
16
+
17
+ /** Explicitly exported identity material. Inspection and JSON redact its key. */
18
+ export class InboxCredentials {
19
+ #recoveryKey;
20
+
21
+ constructor(address, recoveryKey) {
22
+ this.address = address;
23
+ this.#recoveryKey = recoveryKey;
24
+ }
25
+
26
+ /** Sensitive secret: access only to store securely or restore an identity. */
27
+ get recoveryKey() {
28
+ return this.#recoveryKey;
29
+ }
30
+
31
+ toJSON() {
32
+ return { address: this.address, recoveryKey: "[redacted]" };
33
+ }
34
+
35
+ [inspect.custom]() {
36
+ return "InboxCredentials { [redacted] }";
37
+ }
38
+ }
39
+
40
+ function deadlineFor(timeoutMs) {
41
+ if (timeoutMs === undefined) return Infinity;
42
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
43
+ throw new RangeError(
44
+ "timeoutMs must be a positive safe integer, or omitted for no timeout.",
45
+ );
46
+ }
47
+
48
+ return performance.now() + timeoutMs;
49
+ }
50
+
51
+ function nativeFilter(filter) {
52
+ if (filter == null) return undefined;
53
+
54
+ const { envelopeSender, messageId, hasAttachments, ...fields } = filter;
55
+ return {
56
+ ...fields,
57
+ envelope_sender: envelopeSender,
58
+ message_id: messageId,
59
+ has_attachments: hasAttachments,
60
+ };
61
+ }
62
+
63
+ let nativeCall;
64
+
65
+ function loadNative() {
66
+ if (nativeCall) return nativeCall;
67
+
68
+ const name =
69
+ process.platform === "win32"
70
+ ? "ahrara_native.dll"
71
+ : process.platform === "darwin"
72
+ ? "libahrara_native.dylib"
73
+ : "libahrara_native.so";
74
+ const path =
75
+ process.env.AHRARA_NATIVE_LIBRARY ||
76
+ fileURLToPath(
77
+ new URL(
78
+ `./native/${process.platform}-${process.arch}/${name}`,
79
+ import.meta.url,
80
+ ),
81
+ );
82
+ if (!isAbsolute(path) || !existsSync(path)) {
83
+ throw new AhraraError(
84
+ "native_library_missing",
85
+ "Install a package containing the native library for this platform, or set AHRARA_NATIVE_LIBRARY to its absolute path.",
86
+ );
87
+ }
88
+
89
+ const library = koffi.load(path);
90
+ if (library.func("uint32_t ahrara_abi_version(void)")() !== 1) {
91
+ throw new AhraraError(
92
+ "abi_mismatch",
93
+ "The native library requires a different SDK version.",
94
+ );
95
+ }
96
+
97
+ const release = library.func("void ahrara_string_free(void *value)");
98
+ const call = promisify(
99
+ library.func("void *ahrara_call(const char *request)").async,
100
+ );
101
+ nativeCall = async (request) => {
102
+ const pointer = await call(JSON.stringify(request));
103
+ if (!pointer)
104
+ throw new AhraraError(
105
+ "native_error",
106
+ "The native library returned no response.",
107
+ );
108
+
109
+ let response;
110
+ try {
111
+ response = JSON.parse(koffi.decode(pointer, "char", -1));
112
+ } finally {
113
+ release(pointer);
114
+ }
115
+ if (!response.ok)
116
+ throw new AhraraError(response.error.code, response.error.message);
117
+
118
+ return response.result;
119
+ };
120
+
121
+ return nativeCall;
122
+ }
123
+
124
+ // Releases inboxes collected without close(); explicit close remains the primary path.
125
+ const unclosed = new FinalizationRegistry(({ call, handle }) =>
126
+ call({ op: "close", handle }).catch(() => {}),
127
+ );
128
+
129
+ /** Owns one relay subscription. Close explicitly; stopping a stream keeps it open. */
130
+ export class Inbox {
131
+ #handle;
132
+ #call;
133
+ #closing;
134
+ #queue = Promise.resolve();
135
+
136
+ /** @internal Use createInbox(). */
137
+ constructor(handle, address, call) {
138
+ this.#handle = handle;
139
+ this.address = address;
140
+ this.#call = call;
141
+ unclosed.register(this, { call, handle }, this);
142
+ }
143
+
144
+ /**
145
+ * Consume one matching email, including messages buffered before this call.
146
+ * @param {import('./index.js').WaitOptions} [options] Omit timeoutMs to wait indefinitely.
147
+ * @returns {Promise<import('./index.js').Email>} The next unconsumed matching email.
148
+ * @throws {AhraraError} `timeout`, `inbox_closed`, or a native error.
149
+ * @throws {Error} AbortSignal.reason when cancelled; the inbox remains usable.
150
+ */
151
+ async waitForEmail({ subject, from, filter, ...options } = {}) {
152
+ const result = await this.#wait(
153
+ "wait",
154
+ { subject, from, filter: nativeFilter(filter) },
155
+ options,
156
+ );
157
+
158
+ return result.email;
159
+ }
160
+
161
+ async #wait(op, values, { timeoutMs, signal } = {}) {
162
+ const deadline = deadlineFor(timeoutMs);
163
+ // Waits take turns in call order; a queued wait still observes its
164
+ // deadline, signal, and close.
165
+ const previous = this.#queue;
166
+ let release;
167
+ const finished = new Promise((resolve) => (release = resolve));
168
+ this.#queue = previous.then(() => finished);
169
+ let queued = true;
170
+ previous.then(() => (queued = false));
171
+ try {
172
+ while (queued && performance.now() < deadline) {
173
+ signal?.throwIfAborted();
174
+ const remaining = Math.ceil(deadline - performance.now());
175
+ await Promise.race([
176
+ previous,
177
+ delay(Math.min(100, remaining), undefined, { ref: false }),
178
+ ]);
179
+ }
180
+ if (!queued) {
181
+ do {
182
+ signal?.throwIfAborted();
183
+ if (this.#closing)
184
+ throw new AhraraError("inbox_closed", "The inbox is closed.");
185
+ const remaining = Math.max(
186
+ 1,
187
+ Math.ceil(deadline - performance.now()),
188
+ );
189
+ const result = await this.#call({
190
+ op,
191
+ handle: this.#handle,
192
+ timeout_ms: Math.min(100, remaining),
193
+ ...values,
194
+ });
195
+ if (result.status === "matched") return result;
196
+ } while (performance.now() < deadline);
197
+ }
198
+
199
+ signal?.throwIfAborted();
200
+ throw new AhraraError(
201
+ "timeout",
202
+ `No matching email arrived within ${timeoutMs} ms.`,
203
+ );
204
+ } finally {
205
+ release();
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Yield matching emails sequentially, with no prefetch queue.
211
+ * Finite timeoutMs is the total subscription duration, including consumer work;
212
+ * expiry ends normally. Omit it for an indefinite stream. Breaking the loop,
213
+ * throwing, or aborting leaves the inbox open. Only the active wait owns a waiter.
214
+ * @param {import('./index.js').WaitOptions} [options] Filters, total deadline, and abort signal.
215
+ * @returns {AsyncGenerator<import('./index.js').Email, void, unknown>}
216
+ */
217
+ async *waitForEmails({ timeoutMs, subject, from, filter, signal } = {}) {
218
+ yield* this.#stream({ timeoutMs, signal }, async (remaining) => [
219
+ await this.waitForEmail({
220
+ subject,
221
+ from,
222
+ filter,
223
+ signal,
224
+ timeoutMs: remaining,
225
+ }),
226
+ ]);
227
+ }
228
+
229
+ async *#stream({ timeoutMs, signal }, receive) {
230
+ const deadline = deadlineFor(timeoutMs);
231
+ while (true) {
232
+ signal?.throwIfAborted();
233
+ const remaining = deadline - performance.now();
234
+ if (remaining <= 0) return;
235
+
236
+ let values;
237
+ try {
238
+ values = await receive(
239
+ Number.isFinite(remaining) ? Math.ceil(remaining) : undefined,
240
+ );
241
+ } catch (error) {
242
+ if (error instanceof AhraraError && error.code === "timeout") return;
243
+
244
+ throw error;
245
+ }
246
+
247
+ // A consumed email still yields its first value at the deadline.
248
+ for (const [index, value] of values.entries()) {
249
+ if (index) {
250
+ signal?.throwIfAborted();
251
+ if (performance.now() >= deadline) return;
252
+ }
253
+
254
+ yield value;
255
+ }
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Await handler(email) before receiving another message. Handler errors propagate.
261
+ * Uses the same total deadline/cancellation/lifetime rules as waitForEmails().
262
+ * @param {(email: import('./index.js').Email) => void | Promise<void>} handler
263
+ * @param {import('./index.js').WaitOptions} [options]
264
+ * @returns {Promise<void>} Resolves when a finite subscription expires.
265
+ */
266
+ async receiveEmails(handler, options = {}) {
267
+ if (typeof handler !== "function")
268
+ throw new TypeError("handler must be a function.");
269
+ for await (const email of this.waitForEmails(options)) await handler(email);
270
+ }
271
+
272
+ /**
273
+ * Consume one email with a regex match and return its first match and captures.
274
+ * @param {string} pattern Shared Rust regex syntax, including named captures.
275
+ * @param {import('./index.js').RegexOptions} [options] Source, flags, filter, deadline and cancellation.
276
+ * @returns {Promise<import('./index.js').RegexMatch>} Group zero is the entire match; unmatched captures are null.
277
+ * @throws {AhraraError} Invalid regex (without consuming mail), timeout, closed inbox or native failure.
278
+ */
279
+ async waitForRegexMatch(pattern, options = {}) {
280
+ const result = await this.#search(
281
+ this.#regexQuery(pattern, options, false),
282
+ options,
283
+ );
284
+
285
+ return result.matches[0];
286
+ }
287
+
288
+ /**
289
+ * Yield every regex match across matching emails; no background prefetch.
290
+ * @param {string} pattern Shared Rust regex pattern.
291
+ * @param {import('./index.js').RegexOptions} [options] Total deadline includes consumer work.
292
+ * @returns {AsyncGenerator<import('./index.js').RegexMatch, void, unknown>}
293
+ * @throws {AhraraError | Error} Invalid query, native failure, or AbortSignal.reason; expiry ends normally.
294
+ */
295
+ async *waitForRegexMatches(pattern, options = {}) {
296
+ yield* this.#searchValues(
297
+ this.#regexQuery(pattern, options, true),
298
+ "matches",
299
+ options,
300
+ );
301
+ }
302
+
303
+ /**
304
+ * Consume one email with an XPath result and return its first string value.
305
+ * @param {string} expression XPath evaluated against the email's HTML by the shared core.
306
+ * @param {import('./index.js').SearchOptions} [options] Filter, deadline and cancellation.
307
+ * @returns {Promise<string>} Selected text, attribute, or scalar value.
308
+ * @throws {AhraraError | Error} Invalid XPath, timeout, native failure, or AbortSignal.reason.
309
+ */
310
+ async waitForXPath(expression, options = {}) {
311
+ const result = await this.#search(
312
+ { kind: "xpath", expression, all: false },
313
+ options,
314
+ );
315
+
316
+ return result.values[0];
317
+ }
318
+
319
+ /**
320
+ * Yield every XPath value across matching emails. Finite total expiry ends normally.
321
+ * @param {string} expression Shared core XPath expression.
322
+ * @param {import('./index.js').SearchOptions} [options] No timeout means indefinite; cancellation keeps the inbox open.
323
+ * @returns {AsyncGenerator<string, void, unknown>} Values from each matched email in query order.
324
+ * @throws {AhraraError | Error} Invalid query, native failure, or AbortSignal.reason.
325
+ */
326
+ async *waitForXPathValues(expression, options = {}) {
327
+ yield* this.#searchValues(
328
+ { kind: "xpath", expression, all: true },
329
+ "values",
330
+ options,
331
+ );
332
+ }
333
+
334
+ #regexQuery(
335
+ pattern,
336
+ {
337
+ source = "text",
338
+ ignoreCase = false,
339
+ multiLine = false,
340
+ dotMatchesNewLine = false,
341
+ },
342
+ all,
343
+ ) {
344
+ return {
345
+ kind: "regex",
346
+ pattern,
347
+ source,
348
+ ignore_case: ignoreCase,
349
+ multi_line: multiLine,
350
+ dot_matches_new_line: dotMatchesNewLine,
351
+ all,
352
+ };
353
+ }
354
+
355
+ #search(query, { filter, ...options }) {
356
+ return this.#wait(
357
+ "search",
358
+ { query, filter: nativeFilter(filter) },
359
+ options,
360
+ );
361
+ }
362
+
363
+ #searchValues(query, field, { timeoutMs, signal, ...options }) {
364
+ return this.#stream(
365
+ { timeoutMs, signal },
366
+ async (remaining) =>
367
+ (
368
+ await this.#search(query, {
369
+ ...options,
370
+ signal,
371
+ timeoutMs: remaining,
372
+ })
373
+ )[field],
374
+ );
375
+ }
376
+
377
+ /**
378
+ * Export sensitive credentials explicitly. Keep the Recovery Key out of logs.
379
+ * @returns {Promise<InboxCredentials>} Address and key with redacted inspection/JSON.
380
+ * @throws {AhraraError} If the inbox is closed or native export fails.
381
+ */
382
+ async exportCredentials() {
383
+ const result = await this.#request("export_credentials");
384
+ return new InboxCredentials(result.address, result.recovery_key);
385
+ }
386
+
387
+ /**
388
+ * List inbox-scoped metadata without consuming the receive cursor.
389
+ * @param {import('./index.js').ListOptions} [options] Exclusive ID cursor, page size, and filters.
390
+ * @returns {Promise<import('./index.js').EmailPage>} Metadata page and optional next cursor.
391
+ * @throws {AhraraError} Invalid cursor/page size, closed inbox, or native storage failure.
392
+ */
393
+ listEmails({ afterId = 0, limit = 20, subject, from } = {}) {
394
+ return this.#request("list", { after_id: afterId, limit, subject, from });
395
+ }
396
+
397
+ /**
398
+ * Read a stored email belonging to this inbox.
399
+ * @param {number} id Inbox-owned email ID.
400
+ * @returns {Promise<import('./index.js').Email>} Full parsed message and attachment metadata.
401
+ * @throws {AhraraError} If unavailable, outside this inbox, closed, or native storage fails.
402
+ */
403
+ getEmail(id) {
404
+ return this.#request("get", { id });
405
+ }
406
+
407
+ /**
408
+ * Delete one inbox-owned message from local history.
409
+ * @param {number} id Inbox-owned email ID.
410
+ * @returns {Promise<number>} Deleted count, or zero if no owned message matches.
411
+ * @throws {AhraraError} If closed or the native operation fails.
412
+ */
413
+ async deleteEmail(id) {
414
+ return (await this.#request("delete", { id })).deleted;
415
+ }
416
+
417
+ /**
418
+ * Delete all stored messages belonging to this inbox.
419
+ * @returns {Promise<number>} Number of deleted messages.
420
+ * @throws {AhraraError} If closed or native storage fails.
421
+ */
422
+ async clearEmails() {
423
+ return (await this.#request("clear")).deleted;
424
+ }
425
+
426
+ /**
427
+ * Return attachment metadata and decoded bytes, without writing a file.
428
+ * @param {number} id Inbox-owned email ID.
429
+ * @param {string} attachmentId Identifier from Email.attachments.
430
+ * @returns {Promise<import('./index.js').AttachmentData>} Metadata and binary content.
431
+ * @throws {AhraraError} If unavailable, outside this inbox, closed, or native parsing fails.
432
+ */
433
+ async getAttachment(id, attachmentId) {
434
+ const result = await this.#request("attachment", {
435
+ id,
436
+ attachment_id: attachmentId,
437
+ });
438
+
439
+ return {
440
+ attachment: result.attachment,
441
+ content: Buffer.from(result.content_base64, "base64"),
442
+ };
443
+ }
444
+
445
+ /**
446
+ * Save an attachment; existing files are never overwritten.
447
+ * @param {number} id Inbox-owned email ID.
448
+ * @param {string} attachmentId Identifier from Email.attachments.
449
+ * @param {string} destination Absolute new file path or existing directory; filenames are sanitized.
450
+ * @returns {Promise<string>} Absolute path of the saved file.
451
+ * @throws {AhraraError} Missing attachment, existing destination, invalid path, closed inbox, or I/O failure.
452
+ */
453
+ async saveAttachment(id, attachmentId, destination) {
454
+ return (
455
+ await this.#request("save_attachment", {
456
+ id,
457
+ attachment_id: attachmentId,
458
+ destination,
459
+ })
460
+ ).path;
461
+ }
462
+
463
+ /**
464
+ * Read the original RFC 822 message bytes.
465
+ * @param {number} id Inbox-owned email ID.
466
+ * @returns {Promise<Buffer>} Original message content.
467
+ * @throws {AhraraError} If unavailable, outside this inbox, closed, or native storage fails.
468
+ */
469
+ async getRawEmail(id) {
470
+ return Buffer.from(
471
+ (await this.#request("raw", { id })).content_base64,
472
+ "base64",
473
+ );
474
+ }
475
+
476
+ /**
477
+ * Read sanitized HTML. Remote resources are blocked unless explicitly allowed.
478
+ * @param {number} id Inbox-owned email ID.
479
+ * @param {{allowRemote?: boolean}} [options] Explicit opt-in to remote resource URLs.
480
+ * @returns {Promise<import('./index.js').HtmlEmail>} Sanitized HTML and remote resource count.
481
+ * @throws {AhraraError} If HTML is unavailable, outside this inbox, closed, or native parsing fails.
482
+ */
483
+ getHtml(id, { allowRemote = false } = {}) {
484
+ return this.#request("html", { id, allow_remote: allowRemote });
485
+ }
486
+
487
+ #request(op, values = {}) {
488
+ if (this.#closing)
489
+ return Promise.reject(
490
+ new AhraraError("inbox_closed", "The inbox is closed."),
491
+ );
492
+
493
+ return this.#call({ op, handle: this.#handle, ...values });
494
+ }
495
+
496
+ /**
497
+ * Disconnect and release resources. Idempotent; persistent profiles remain on disk.
498
+ * @returns {Promise<void>} Resolves after the native subscription shuts down.
499
+ * @throws {AhraraError} If native cleanup fails.
500
+ */
501
+ close() {
502
+ unclosed.unregister(this);
503
+ this.#closing ??= this.#call({ op: "close", handle: this.#handle });
504
+ return this.#closing.then(() => undefined);
505
+ }
506
+
507
+ async [Symbol.asyncDispose]() {
508
+ await this.close();
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Authenticate and subscribe before returning an inbox. No CLI or daemon is started.
514
+ * @param {import('./index.js').InboxOptions} [options] Relay trust, persistence, and identity options.
515
+ * @returns {Promise<Inbox>} A ready inbox; messages arriving before the first wait are buffered.
516
+ * @throws {AhraraError} Invalid identity/configuration, profile conflict, or connection failure.
517
+ */
518
+ export async function createInbox(options = {}) {
519
+ const call = loadNative();
520
+ const result = await call({
521
+ op: "create",
522
+ options: {
523
+ server_url: options.serverUrl,
524
+ server_public_key: options.serverPublicKey,
525
+ tls_ca_path: options.tlsCaPath,
526
+ connect_timeout_ms: options.connectTimeoutMs,
527
+ profile_path: options.profilePath,
528
+ credentials: options.credentials && {
529
+ address: options.credentials.address,
530
+ recovery_key: options.credentials.recoveryKey,
531
+ },
532
+ recovery_key: options.recoveryKey,
533
+ address: options.address,
534
+ filter: nativeFilter(options.filter),
535
+ },
536
+ });
537
+
538
+ return new Inbox(result.handle, result.address, call);
539
+ }
540
+
541
+ export const Ahrara = Object.freeze({ createInbox });
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@ahrara/sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Disposable email inboxes powered by the shared Ahrara Rust core",
5
+ "license": "MIT",
6
+ "homepage": "https://ahrara.dev",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Hitmasu/Ahrara.git",
10
+ "directory": "src/sdks/javascript"
11
+ },
12
+ "bugs": "https://github.com/Hitmasu/Ahrara/issues",
13
+ "keywords": [
14
+ "email",
15
+ "disposable-email",
16
+ "testing",
17
+ "qa",
18
+ "playwright",
19
+ "cypress",
20
+ "automation"
21
+ ],
22
+ "type": "module",
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "exports": {
27
+ ".": {
28
+ "types": "./index.d.ts",
29
+ "import": "./index.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "index.js",
34
+ "index.d.ts",
35
+ "native",
36
+ "README.md",
37
+ "LICENSE-THIRD-PARTY.md"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "scripts": {
43
+ "test": "node --test test/inbox.test.mjs",
44
+ "test:types": "tsc --noEmit --strict --module nodenext --target es2022 test/types.ts",
45
+ "test:playwright": "playwright test",
46
+ "test:cypress": "cypress run --browser electron",
47
+ "format:check": "prettier --check index.js index.d.ts package.json package-lock.json \"*.config.*\" \"test/**/*.{js,mjs,ts}\" \"cypress/**/*.js\""
48
+ },
49
+ "dependencies": {
50
+ "koffi": "^3.0.0"
51
+ },
52
+ "devDependencies": {
53
+ "@playwright/test": "1.58.2",
54
+ "@types/node": "^24.0.0",
55
+ "cypress": "15.21.1",
56
+ "nodemailer": "10.0.13",
57
+ "prettier": "3.6.2",
58
+ "typescript": "^5.9.0"
59
+ }
60
+ }