@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/README.md CHANGED
@@ -1,3 +1,156 @@
1
- # Temporary Holding Version
1
+ # Ahrara for JavaScript and TypeScript
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Create an email address from Node.js, wait for messages, and extract verification
4
+ codes or links in JavaScript or TypeScript. Requires Node.js 20+; install with
5
+ `npm install @ahrara/sdk`. TypeScript declarations are included. The SDK connects
6
+ directly to the relay and runs in the application or test runner, not in the
7
+ browser.
8
+
9
+ ```js
10
+ import { Ahrara } from '@ahrara/sdk';
11
+
12
+ const inbox = await Ahrara.createInbox();
13
+ try {
14
+ // Submit inbox.address in your application.
15
+ const email = await inbox.waitForEmail(); // No implicit timeout.
16
+ // Assert on email.subject / email.text_body.
17
+ } finally {
18
+ await inbox.close();
19
+ }
20
+ ```
21
+
22
+ Creation returns after authentication and subscription. Mail arriving before a
23
+ wait is buffered. Each matching receive is consumed once, while storage retains
24
+ the message until deleted. Create separate inboxes for parallel tests. Concurrent
25
+ waits on one inbox are queued and run one at a time, in call order.
26
+
27
+ ## Waits, streams, and callbacks
28
+
29
+ `waitForEmail({ timeoutMs, subject, from, signal })` waits indefinitely when
30
+ `timeoutMs` is omitted. An explicit positive integer deadline is in milliseconds
31
+ and throws `AhraraError` with code `timeout`. `subject` and `from` are ASCII
32
+ case-insensitive substrings. AbortSignal cancellation and `close()` interrupt
33
+ waits; cancelling a wait preserves the inbox. A simultaneously matched email may
34
+ win the cancellation race. Native work runs off the Node event loop.
35
+
36
+ ```js
37
+ for await (const email of inbox.waitForEmails({ timeoutMs: 10_000 })) {
38
+ await verify(email);
39
+ if (finished()) break;
40
+ }
41
+
42
+ await inbox.receiveEmails(async email => {
43
+ await verify(email);
44
+ }, { timeoutMs: 10_000 });
45
+ ```
46
+
47
+ A stream's finite timeout is its **total duration**, including consumer/callback
48
+ work, and expiry ends normally. Omit timeout to keep receiving indefinitely.
49
+ Callbacks are awaited sequentially; errors propagate. Breaking a stream,
50
+ returning its iterator, cancellation, or callback failure leaves the inbox open.
51
+ There is no prefetch or hidden message queue. Use an AbortSignal to interrupt an
52
+ active `next()`; awaiting `return()` alone cannot interrupt an in-flight receive.
53
+ The application remains responsible for closing the inbox.
54
+
55
+ ## Filters, regex captures, and XPath
56
+
57
+ Pass `filter` to `createInbox()` to discard nonmatching incoming email before
58
+ storage, or to a wait/search method to leave nonmatching email available for
59
+ later waits. All filter fields are ANDed. `sender`, `subject`, `to`, `cc`, `text`,
60
+ and `headers: { name: value }` use case-insensitive substring matching.
61
+ `recipient`, `envelopeSender`, and `messageId` match exactly, ignoring case;
62
+ `hasAttachments` selects presence or absence of attachments. Header names ignore
63
+ case. A per-wait filter adds to the inbox's receipt filter.
64
+
65
+ Set `filter: { regex: true, subject: '^(Verification|Confirm email)$' }` to
66
+ interpret all string conditions as case-insensitive Rust regex patterns; regex
67
+ queries below are case-sensitive unless `ignoreCase` is set. The top-level
68
+ `subject`/`from` wait options stay literal. See the
69
+ [shared filter rules](https://github.com/Hitmasu/Ahrara/blob/main/docs/sdks.md#filters-and-extraction)
70
+ for anchoring and limits.
71
+
72
+ ```js
73
+ const match = await inbox.waitForRegexMatch('code=(?<code>[0-9]+)', {
74
+ filter: { sender: 'example.test', subject: 'Verify' },
75
+ ignoreCase: true,
76
+ });
77
+ const code = match.groups.find(group => group.name === 'code').value;
78
+
79
+ for await (const match of inbox.waitForRegexMatches('[0-9]+', { timeoutMs: 10_000 })) {
80
+ await verifyCode(match.value);
81
+ }
82
+
83
+ const link = await inbox.waitForXPath('//a[@id="verify"]/@href');
84
+ for await (const value of inbox.waitForXPathValues('//a/@href', { timeoutMs: 10_000 })) {
85
+ await verifyLink(value);
86
+ }
87
+ ```
88
+
89
+ Regex uses the shared [Rust regex syntax](https://docs.rs/regex/latest/regex/#syntax),
90
+ including named groups; lookaround and backreferences are unsupported. Options
91
+ are `source: 'text' | 'html' | 'subject' | 'raw'` (default full decoded text),
92
+ `ignoreCase`, `multiLine`, and `dotMatchesNewLine`. A result contains `email_id`,
93
+ `value`, and indexed `groups`; group zero is the whole match and unmatched optional
94
+ groups have a null value. HTML searches use original decoded HTML.
95
+
96
+ Singular calls return the first value/match and throw on timeout. Plural methods
97
+ yield **every match/value across multiple emails**, with one total timeout that
98
+ includes consumer work. Finite expiry ends normally; omitted timeout is indefinite.
99
+ `signal` cancels either form. Invalid expressions and filters throw before
100
+ consuming email. Each matched email is consumed once: stopping partway through
101
+ its results does not replay the remaining values, but its stored message remains
102
+ readable.
103
+
104
+ ## Persistence and identity
105
+
106
+ `createInbox({ profilePath: '/absolute/profile/directory' })` retains identity,
107
+ address, encrypted history, and consumed-receive state across close/reopen. Omit
108
+ `profilePath` for a disposable temporary profile. The same active profile cannot
109
+ be opened twice. Explicitly clear/delete messages to remove retained history.
110
+
111
+ ```js
112
+ const credentials = await inbox.exportCredentials();
113
+ await inbox.close();
114
+ const restored = await Ahrara.createInbox({ credentials });
115
+ ```
116
+
117
+ Credentials restore the identity/address, with fresh local history unless a
118
+ persistent profile is supplied. Inspection and JSON serialization redact the
119
+ Recovery Key; access `credentials.recoveryKey` only for deliberate secure storage.
120
+ Never log it. Alternatively pass `recoveryKey` and optional owned `address`;
121
+ these cannot be combined with `credentials`. An `address` alone requires an
122
+ existing profile identity. Unowned addresses and mismatched profile keys fail.
123
+
124
+ Connection options are `serverUrl`, `serverPublicKey`, `tlsCaPath`, and
125
+ `connectTimeoutMs` (1..300000 ms, default 20000). Omit them for the hosted relay,
126
+ whose verified public key the package embeds; a private relay's public key must be
127
+ independently verified. Custom CAs keep TLS verification enabled.
128
+
129
+ ## Stored email and attachments
130
+
131
+ All operations are scoped to the inbox's address. Listing/getting does not
132
+ consume receive state. Native errors retain their code in `AhraraError.code`.
133
+
134
+ | Method | Result |
135
+ | --- | --- |
136
+ | `listEmails({ afterId, limit, subject, from })` | `{ emails, next_cursor }` metadata page; limit 1..100 |
137
+ | `getEmail(id)` | Full parsed email with attachment metadata |
138
+ | `deleteEmail(id)` / `clearEmails()` | Number of messages removed |
139
+ | `getAttachment(id, attachmentId)` | `{ attachment, content: Buffer }` |
140
+ | `saveAttachment(id, attachmentId, absolutePath)` | Saved file path; never overwrites; directories use a sanitized filename |
141
+ | `getRawEmail(id)` | Original RFC 822 bytes as Buffer |
142
+ | `getHtml(id, { allowRemote: false })` | Sanitized `{ html, remote_resources }`; remote resources blocked by default |
143
+
144
+ Email fields use the native snake_case schema, including `from`, `to`, `subject`,
145
+ `text_body`, `attachments`, and `parse_error`. Extracted text is capped at 256 KiB
146
+ and reports `text_body_truncated`. HTML and attachments are fetched explicitly.
147
+ Treat message content as untrusted data.
148
+
149
+ ## Test frameworks
150
+
151
+ Playwright uses a test fixture owning an inbox; see
152
+ [`signup.spec.js`](https://github.com/Hitmasu/Ahrara/blob/main/src/sdks/javascript/test/signup.spec.js).
153
+ Cypress uses Node `setupNodeEvents` and `cy.task`, since browser code cannot load
154
+ the native library; see
155
+ [`cypress.config.js`](https://github.com/Hitmasu/Ahrara/blob/main/src/sdks/javascript/cypress.config.js).
156
+ Node/Jest/Vitest use the same API.
package/index.d.ts ADDED
@@ -0,0 +1,235 @@
1
+ /** An address and its sensitive Recovery Key; never log the key. */
2
+ export interface CredentialsInput {
3
+ address: string;
4
+ recoveryKey: string;
5
+ }
6
+
7
+ /** Exported credentials redact inspection and JSON; access recoveryKey explicitly. */
8
+ export class InboxCredentials implements CredentialsInput {
9
+ constructor(address: string, recoveryKey: string);
10
+ readonly address: string;
11
+ readonly recoveryKey: string;
12
+ toJSON(): { address: string; recoveryKey: string };
13
+ }
14
+
15
+ /** Connection, local persistence, and optional identity restoration. */
16
+ export interface InboxOptions {
17
+ /** Receipt filter applied before storage. Rejected emails are dropped. */
18
+ filter?: EmailFilter;
19
+ /** wss:// relay URL; defaults to the release core's production endpoint. */
20
+ serverUrl?: string;
21
+ /** Independently verified public Noise key, not a Recovery Key. */
22
+ serverPublicKey?: string;
23
+ /** Optional custom trusted CA PEM file. TLS verification remains enabled. */
24
+ tlsCaPath?: string;
25
+ /** Connection deadline in milliseconds, 1..300000; core default 20000. */
26
+ connectTimeoutMs?: number;
27
+ /** Absolute persistent profile directory. Omit for an isolated temporary profile. */
28
+ profilePath?: string;
29
+ /** Restore exported credentials; mutually exclusive with recoveryKey/address. */
30
+ credentials?: CredentialsInput;
31
+ /** Sensitive identity import, optionally paired with an owned address. */
32
+ recoveryKey?: string;
33
+ /** Owned address to select; alone requires an existing profile identity. */
34
+ address?: string;
35
+ }
36
+
37
+ export interface WaitOptions {
38
+ /** Additional filter; nonmatching stored emails remain available for later waits. */
39
+ filter?: EmailFilter;
40
+ /** Positive integer milliseconds. Omitted means indefinite; streams use a total deadline. */
41
+ timeoutMs?: number;
42
+ /** Substring filter using the core's ASCII case-insensitive matching. */
43
+ subject?: string;
44
+ /** Sender substring filter using the same matching rules. */
45
+ from?: string;
46
+ /** Abort only this wait/subscription. Cancellation leaves the inbox open. */
47
+ signal?: AbortSignal;
48
+ }
49
+
50
+ /**
51
+ * Fields are ANDed. Strings match case-insensitive substrings, except recipient,
52
+ * envelopeSender, and messageId, which match the whole value. With `regex: true`,
53
+ * strings are case-insensitive Rust regex patterns: those three are anchored to
54
+ * the whole value; the others match anywhere.
55
+ */
56
+ export interface EmailFilter {
57
+ /** Interpret string conditions, including header values, as regex. Header names stay literal. */
58
+ regex?: boolean;
59
+ sender?: string;
60
+ subject?: string;
61
+ to?: string;
62
+ cc?: string;
63
+ text?: string;
64
+ /** Whole SMTP recipient, ignoring case; an anchored pattern with `regex`. */
65
+ recipient?: string;
66
+ /** Whole SMTP envelope sender, ignoring case; an anchored pattern with `regex`. */
67
+ envelopeSender?: string;
68
+ /** Whole Message-ID header, ignoring case; an anchored pattern with `regex`. */
69
+ messageId?: string;
70
+ hasAttachments?: boolean;
71
+ /** Literal, case-insensitive header names; each value matches anywhere in a header value. */
72
+ headers?: Record<string, string>;
73
+ }
74
+
75
+ /** Shared search lifecycle. Single waits throw on timeout; streams end normally. */
76
+ export interface SearchOptions {
77
+ /** Positive integer milliseconds, or omitted for indefinite. Streams use total duration. */
78
+ timeoutMs?: number;
79
+ /** Abort this search/stream while keeping the inbox open. */
80
+ signal?: AbortSignal;
81
+ /** Nonmatching stored emails remain unconsumed. */
82
+ filter?: EmailFilter;
83
+ }
84
+
85
+ export interface RegexOptions extends SearchOptions {
86
+ /** Search full decoded text (default), original HTML, subject, or raw RFC 822 text. */
87
+ source?: "text" | "html" | "subject" | "raw";
88
+ /** Defaults to false: query regex is case-sensitive, unlike filter regex. */
89
+ ignoreCase?: boolean;
90
+ multiLine?: boolean;
91
+ dotMatchesNewLine?: boolean;
92
+ }
93
+
94
+ export interface RegexGroup {
95
+ /** Zero identifies the whole match. Remaining indices follow capture order. */
96
+ index: number;
97
+ /** Named capture identifier, or null for unnamed groups. */
98
+ name?: string | null;
99
+ /** Null when an optional group did not participate in this match. */
100
+ value?: string | null;
101
+ }
102
+
103
+ /** A matching substring and all its captures, associated with an inbox-owned email. */
104
+ export interface RegexMatch {
105
+ email_id: number;
106
+ value: string;
107
+ groups: RegexGroup[];
108
+ }
109
+
110
+ export interface ListOptions {
111
+ /** Exclusive message-ID cursor; defaults to 0. */
112
+ afterId?: number;
113
+ /** Page size 1..100; defaults to 20. */
114
+ limit?: number;
115
+ subject?: string;
116
+ from?: string;
117
+ }
118
+
119
+ /** Stored metadata. Field names match the shared native email schema. */
120
+ export interface EmailMetadata {
121
+ id: number;
122
+ transfer_id: string;
123
+ identity_fingerprint: string;
124
+ recipient: string;
125
+ envelope_sender: string;
126
+ received_at: string;
127
+ from: string;
128
+ to: string;
129
+ subject: string;
130
+ message_id: string | null;
131
+ size_bytes: number;
132
+ }
133
+
134
+ export interface Attachment {
135
+ attachment_id: string;
136
+ filename: string;
137
+ content_type: string;
138
+ size: number;
139
+ content_disposition: string;
140
+ }
141
+
142
+ export interface Email extends EmailMetadata {
143
+ cc: string;
144
+ date: string;
145
+ forwarded_to: string | null;
146
+ /** Extracted text, capped by the core at 256 KiB. */
147
+ text_body: string;
148
+ text_body_truncated: boolean;
149
+ html_available: boolean;
150
+ attachments: Attachment[];
151
+ parse_error: boolean;
152
+ }
153
+
154
+ export interface EmailPage {
155
+ emails: EmailMetadata[];
156
+ next_cursor: number | null;
157
+ }
158
+
159
+ export interface AttachmentData {
160
+ attachment: Attachment;
161
+ content: Buffer;
162
+ }
163
+
164
+ export interface HtmlEmail {
165
+ html: string;
166
+ remote_resources: number;
167
+ }
168
+
169
+ /** Native errors retain a stable code, including timeout/inbox_closed. */
170
+ export class AhraraError extends Error {
171
+ constructor(code: string, message: string);
172
+ readonly code: string;
173
+ }
174
+
175
+ /** A ready, caller-owned inbox. Close explicitly or use await using. */
176
+ export class Inbox {
177
+ private constructor();
178
+ readonly address: string;
179
+ /** Consume one matching email. Concurrent waits queue. Omitted timeout waits indefinitely. Throws on finite timeout. */
180
+ waitForEmail(options?: WaitOptions): Promise<Email>;
181
+ /** Sequential stream; total timeout includes consumer work and expires normally. */
182
+ waitForEmails(options?: WaitOptions): AsyncGenerator<Email, void, unknown>;
183
+ /** Await each callback; propagate callback errors. No prefetch or hidden background queue. */
184
+ receiveEmails(
185
+ handler: (email: Email) => void | Promise<void>,
186
+ options?: WaitOptions,
187
+ ): Promise<void>;
188
+ /** First regex match with captures. Invalid queries never consume email. */
189
+ waitForRegexMatch(
190
+ pattern: string,
191
+ options?: RegexOptions,
192
+ ): Promise<RegexMatch>;
193
+ /** Every match across emails; total deadline includes consumer work. Expiry ends normally. */
194
+ waitForRegexMatches(
195
+ pattern: string,
196
+ options?: RegexOptions,
197
+ ): AsyncGenerator<RegexMatch, void, unknown>;
198
+ /** First XPath string value from a matching email's HTML. */
199
+ waitForXPath(expression: string, options?: SearchOptions): Promise<string>;
200
+ /** Every XPath value across emails; same lifetime and deadline rules as regex streams. */
201
+ waitForXPathValues(
202
+ expression: string,
203
+ options?: SearchOptions,
204
+ ): AsyncGenerator<string, void, unknown>;
205
+ /** Export identity credentials explicitly; their Recovery Key is sensitive. */
206
+ exportCredentials(): Promise<InboxCredentials>;
207
+ /** Read metadata without consuming the receive cursor. */
208
+ listEmails(options?: ListOptions): Promise<EmailPage>;
209
+ /** Read full parsed email; IDs are scoped to this inbox's address. */
210
+ getEmail(id: number): Promise<Email>;
211
+ /** Delete one stored message and return the number removed. */
212
+ deleteEmail(id: number): Promise<number>;
213
+ /** Clear only this inbox's messages and return the number removed. */
214
+ clearEmails(): Promise<number>;
215
+ /** Return attachment metadata and bytes without touching the filesystem. */
216
+ getAttachment(id: number, attachmentId: string): Promise<AttachmentData>;
217
+ /** Save to an absolute file/directory path; never overwrite. Returns the saved path. */
218
+ saveAttachment(
219
+ id: number,
220
+ attachmentId: string,
221
+ destination: string,
222
+ ): Promise<string>;
223
+ /** Original RFC 822 message bytes. */
224
+ getRawEmail(id: number): Promise<Buffer>;
225
+ /** Sanitized HTML. Remote resources are blocked by default. */
226
+ getHtml(id: number, options?: { allowRemote?: boolean }): Promise<HtmlEmail>;
227
+ /** Idempotent shutdown. Interrupts waits; persistent profile files remain. */
228
+ close(): Promise<void>;
229
+ [Symbol.asyncDispose](): Promise<void>;
230
+ }
231
+
232
+ /** Return after authentication/subscription, or throw a native configuration/connection error. */
233
+ export function createInbox(options?: InboxOptions): Promise<Inbox>;
234
+
235
+ export const Ahrara: Readonly<{ createInbox: typeof createInbox }>;