@haiai/haiai 0.1.2

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.
Files changed (153) hide show
  1. package/README.md +127 -0
  2. package/bin/haiai.cjs +70 -0
  3. package/dist/cjs/a2a.js +352 -0
  4. package/dist/cjs/a2a.js.map +1 -0
  5. package/dist/cjs/agent.js +236 -0
  6. package/dist/cjs/agent.js.map +1 -0
  7. package/dist/cjs/client.js +2168 -0
  8. package/dist/cjs/client.js.map +1 -0
  9. package/dist/cjs/config.js +176 -0
  10. package/dist/cjs/config.js.map +1 -0
  11. package/dist/cjs/errors.js +102 -0
  12. package/dist/cjs/errors.js.map +1 -0
  13. package/dist/cjs/hash.js +52 -0
  14. package/dist/cjs/hash.js.map +1 -0
  15. package/dist/cjs/index.js +84 -0
  16. package/dist/cjs/index.js.map +1 -0
  17. package/dist/cjs/integrations.js +193 -0
  18. package/dist/cjs/integrations.js.map +1 -0
  19. package/dist/cjs/jacs.js +66 -0
  20. package/dist/cjs/jacs.js.map +1 -0
  21. package/dist/cjs/mime.js +100 -0
  22. package/dist/cjs/mime.js.map +1 -0
  23. package/dist/cjs/package.json +3 -0
  24. package/dist/cjs/signing.js +190 -0
  25. package/dist/cjs/signing.js.map +1 -0
  26. package/dist/cjs/sse.js +76 -0
  27. package/dist/cjs/sse.js.map +1 -0
  28. package/dist/cjs/types.js +6 -0
  29. package/dist/cjs/types.js.map +1 -0
  30. package/dist/cjs/verify.js +76 -0
  31. package/dist/cjs/verify.js.map +1 -0
  32. package/dist/cjs/ws.js +206 -0
  33. package/dist/cjs/ws.js.map +1 -0
  34. package/dist/esm/a2a.js +305 -0
  35. package/dist/esm/a2a.js.map +1 -0
  36. package/dist/esm/agent.js +231 -0
  37. package/dist/esm/agent.js.map +1 -0
  38. package/dist/esm/client.js +2131 -0
  39. package/dist/esm/client.js.map +1 -0
  40. package/dist/esm/config.js +171 -0
  41. package/dist/esm/config.js.map +1 -0
  42. package/dist/esm/errors.js +88 -0
  43. package/dist/esm/errors.js.map +1 -0
  44. package/dist/esm/hash.js +49 -0
  45. package/dist/esm/hash.js.map +1 -0
  46. package/dist/esm/index.js +27 -0
  47. package/dist/esm/index.js.map +1 -0
  48. package/dist/esm/integrations.js +147 -0
  49. package/dist/esm/integrations.js.map +1 -0
  50. package/dist/esm/jacs.js +61 -0
  51. package/dist/esm/jacs.js.map +1 -0
  52. package/dist/esm/mime.js +97 -0
  53. package/dist/esm/mime.js.map +1 -0
  54. package/dist/esm/signing.js +183 -0
  55. package/dist/esm/signing.js.map +1 -0
  56. package/dist/esm/sse.js +73 -0
  57. package/dist/esm/sse.js.map +1 -0
  58. package/dist/esm/types.js +5 -0
  59. package/dist/esm/types.js.map +1 -0
  60. package/dist/esm/verify.js +72 -0
  61. package/dist/esm/verify.js.map +1 -0
  62. package/dist/esm/ws.js +168 -0
  63. package/dist/esm/ws.js.map +1 -0
  64. package/dist/types/a2a.d.ts +52 -0
  65. package/dist/types/a2a.d.ts.map +1 -0
  66. package/dist/types/agent.d.ts +202 -0
  67. package/dist/types/agent.d.ts.map +1 -0
  68. package/dist/types/client.d.ts +486 -0
  69. package/dist/types/client.d.ts.map +1 -0
  70. package/dist/types/config.d.ts +31 -0
  71. package/dist/types/config.d.ts.map +1 -0
  72. package/dist/types/errors.d.ts +50 -0
  73. package/dist/types/errors.d.ts.map +1 -0
  74. package/dist/types/hash.d.ts +32 -0
  75. package/dist/types/hash.d.ts.map +1 -0
  76. package/dist/types/index.d.ts +22 -0
  77. package/dist/types/index.d.ts.map +1 -0
  78. package/dist/types/integrations.d.ts +25 -0
  79. package/dist/types/integrations.d.ts.map +1 -0
  80. package/dist/types/jacs.d.ts +26 -0
  81. package/dist/types/jacs.d.ts.map +1 -0
  82. package/dist/types/mime.d.ts +39 -0
  83. package/dist/types/mime.d.ts.map +1 -0
  84. package/dist/types/signing.d.ts +58 -0
  85. package/dist/types/signing.d.ts.map +1 -0
  86. package/dist/types/sse.d.ts +8 -0
  87. package/dist/types/sse.d.ts.map +1 -0
  88. package/dist/types/types.d.ts +652 -0
  89. package/dist/types/types.d.ts.map +1 -0
  90. package/dist/types/verify.d.ts +20 -0
  91. package/dist/types/verify.d.ts.map +1 -0
  92. package/dist/types/ws.d.ts +30 -0
  93. package/dist/types/ws.d.ts.map +1 -0
  94. package/examples/a2a_quickstart.ts +138 -0
  95. package/examples/hai_quickstart.ts +111 -0
  96. package/examples/mcp_quickstart.ts +53 -0
  97. package/npm/@haiai/cli-darwin-arm64/package.json +16 -0
  98. package/npm/@haiai/cli-darwin-x64/package.json +16 -0
  99. package/npm/@haiai/cli-linux-arm64/package.json +16 -0
  100. package/npm/@haiai/cli-linux-x64/package.json +16 -0
  101. package/npm/@haiai/cli-win32-x64/package.json +16 -0
  102. package/package.json +68 -0
  103. package/scripts/build-platform-packages.js +132 -0
  104. package/scripts/smoke-package.cjs +114 -0
  105. package/scripts/write-cjs-package.cjs +9 -0
  106. package/src/a2a.ts +463 -0
  107. package/src/agent.ts +302 -0
  108. package/src/client.ts +2504 -0
  109. package/src/config.ts +204 -0
  110. package/src/errors.ts +99 -0
  111. package/src/hash.ts +66 -0
  112. package/src/index.ts +163 -0
  113. package/src/integrations.ts +210 -0
  114. package/src/jacs.ts +86 -0
  115. package/src/mime.ts +131 -0
  116. package/src/signing.ts +233 -0
  117. package/src/sse.ts +86 -0
  118. package/src/types.ts +773 -0
  119. package/src/verify.ts +89 -0
  120. package/src/ws.ts +198 -0
  121. package/tests/_debug_jacs.cjs +29 -0
  122. package/tests/a2a-contract.test.ts +271 -0
  123. package/tests/a2a-fixtures.test.ts +73 -0
  124. package/tests/a2a.test.ts +379 -0
  125. package/tests/binary.test.ts +90 -0
  126. package/tests/client-api-methods.test.ts +176 -0
  127. package/tests/client-path-escaping.test.ts +80 -0
  128. package/tests/client-register.test.ts +61 -0
  129. package/tests/config.test.ts +281 -0
  130. package/tests/contract.test.ts +360 -0
  131. package/tests/cross-lang-contract.test.ts +67 -0
  132. package/tests/email-conformance.test.ts +289 -0
  133. package/tests/email-integration.test.ts +217 -0
  134. package/tests/email.test.ts +767 -0
  135. package/tests/errors.test.ts +167 -0
  136. package/tests/init-contract.test.ts +129 -0
  137. package/tests/integrations.test.ts +132 -0
  138. package/tests/jacs-passthrough.test.ts +125 -0
  139. package/tests/key-cache.test.ts +201 -0
  140. package/tests/key-integration.test.ts +119 -0
  141. package/tests/key-lookups.test.ts +187 -0
  142. package/tests/key-rotation.test.ts +362 -0
  143. package/tests/mime.test.ts +127 -0
  144. package/tests/security.test.ts +109 -0
  145. package/tests/setup.ts +60 -0
  146. package/tests/signing.test.ts +142 -0
  147. package/tests/sse.test.ts +125 -0
  148. package/tests/types.test.ts +294 -0
  149. package/tests/verify-link.test.ts +81 -0
  150. package/tests/ws.test.ts +213 -0
  151. package/tsconfig.cjs.json +11 -0
  152. package/tsconfig.json +22 -0
  153. package/vitest.config.ts +11 -0
@@ -0,0 +1,289 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { HaiClient } from '../src/client.js';
6
+ import { generateTestKeypair as generateKeypair } from './setup.js';
7
+ import {
8
+ EmailNotActiveError,
9
+ RecipientNotFoundError,
10
+ RateLimitedError,
11
+ } from '../src/errors.js';
12
+ import type {
13
+ EmailVerificationResultV2,
14
+ FieldStatus,
15
+ } from '../src/types.js';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Fixture loading
19
+ // ---------------------------------------------------------------------------
20
+
21
+ interface ConformanceFixture {
22
+ verification_result_v2_schema: {
23
+ required_fields: Record<string, string>;
24
+ field_status_values: string[];
25
+ };
26
+ api_contracts: {
27
+ sign_email: { method: string; path: string; request_content_type: string };
28
+ verify_email: { method: string; path: string; request_content_type: string };
29
+ send_email: { excluded_fields: string[] };
30
+ };
31
+ mock_verify_response: { json: Record<string, unknown> };
32
+ }
33
+
34
+ function loadConformanceFixture(): ConformanceFixture {
35
+ const here = dirname(fileURLToPath(import.meta.url));
36
+ const fixturePath = resolve(here, '../../fixtures/email_conformance.json');
37
+ return JSON.parse(readFileSync(fixturePath, 'utf-8')) as ConformanceFixture;
38
+ }
39
+
40
+ async function makeClient(baseUrl: string): Promise<HaiClient> {
41
+ const keypair = generateKeypair();
42
+ const client = await HaiClient.fromCredentials('test-agent-001', keypair.privateKeyPem, { url: baseUrl, privateKeyPassphrase: 'keygen-password' });
43
+ client.agentEmail = 'test@hai.ai';
44
+ return client;
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // EmailVerificationResultV2 structural conformance
49
+ // ---------------------------------------------------------------------------
50
+
51
+ describe('email conformance: mock verify response deserialization', () => {
52
+ const fixture = loadConformanceFixture();
53
+
54
+ afterEach(() => {
55
+ vi.restoreAllMocks();
56
+ });
57
+
58
+ it('deserializes mock response into EmailVerificationResultV2 via verifyEmail', async () => {
59
+ const mockJson = fixture.mock_verify_response.json;
60
+
61
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
62
+ ok: true,
63
+ status: 200,
64
+ json: () => Promise.resolve(mockJson),
65
+ }));
66
+
67
+ const client = await makeClient('https://mock.hai.ai');
68
+ const result: EmailVerificationResultV2 = await client.verifyEmail('raw email content');
69
+
70
+ expect(result.valid).toBe(true);
71
+ expect(result.jacsId).toBe('conformance-test-agent-001');
72
+ expect(result.algorithm).toBe('ed25519');
73
+ expect(result.reputationTier).toBe('established');
74
+ expect(result.dnsVerified).toBe(true);
75
+ expect(result.error).toBeNull();
76
+
77
+ // field_results
78
+ expect(result.fieldResults).toHaveLength(4);
79
+ expect(result.fieldResults[0].field).toBe('subject');
80
+ expect(result.fieldResults[0].status).toBe('pass');
81
+ expect(result.fieldResults[3].field).toBe('date');
82
+ expect(result.fieldResults[3].status).toBe('modified');
83
+
84
+ // chain
85
+ expect(result.chain).toHaveLength(1);
86
+ expect(result.chain[0].signer).toBe('agent@hai.ai');
87
+ expect(result.chain[0].jacsId).toBe('conformance-test-agent-001');
88
+ expect(result.chain[0].valid).toBe(true);
89
+ expect(result.chain[0].forwarded).toBe(false);
90
+
91
+ // agent_status and benchmarks_completed (TASK_012)
92
+ expect(result.agentStatus).toBe('active');
93
+ expect(result.benchmarksCompleted).toEqual(['free_chaotic']);
94
+ });
95
+ });
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Content hash golden vector conformance (TASK_013)
99
+ // ---------------------------------------------------------------------------
100
+
101
+ describe('email conformance: content hash golden vectors', () => {
102
+ it('all golden vectors produce the expected content hash', async () => {
103
+ const { computeContentHash } = await import('../src/hash.js');
104
+ const fixture = loadConformanceFixture() as ConformanceFixture & {
105
+ content_hash_golden: {
106
+ vectors: Array<{
107
+ name: string;
108
+ subject: string;
109
+ body: string;
110
+ attachments: Array<{ filename: string; content_type: string; data_utf8: string }>;
111
+ expected_hash: string;
112
+ }>;
113
+ };
114
+ };
115
+
116
+ for (const vector of fixture.content_hash_golden.vectors) {
117
+ const result = computeContentHash(vector.subject, vector.body, vector.attachments);
118
+ expect(result).toBe(vector.expected_hash);
119
+ }
120
+ });
121
+ });
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // MIME round-trip conformance (TASK_014)
125
+ // ---------------------------------------------------------------------------
126
+
127
+ describe('email conformance: MIME round-trip content hash', () => {
128
+ it('produces expected content hash from round-trip input', async () => {
129
+ const { computeContentHash } = await import('../src/hash.js');
130
+ const fixture = loadConformanceFixture() as ConformanceFixture & {
131
+ mime_round_trip: {
132
+ input: {
133
+ subject: string;
134
+ body: string;
135
+ attachments: Array<{ filename: string; content_type: string; data_utf8: string }>;
136
+ };
137
+ expected_content_hash: string;
138
+ };
139
+ };
140
+
141
+ const { input, expected_content_hash: expectedHash } = fixture.mime_round_trip;
142
+ const result = computeContentHash(input.subject, input.body, input.attachments);
143
+ expect(result).toBe(expectedHash);
144
+ });
145
+ });
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // FieldStatus enum conformance
149
+ // ---------------------------------------------------------------------------
150
+
151
+ describe('email conformance: FieldStatus values', () => {
152
+ const fixture = loadConformanceFixture();
153
+
154
+ it('all fixture field_status_values are valid FieldStatus literals', () => {
155
+ const validStatuses: FieldStatus[] = ['pass', 'modified', 'fail', 'unverifiable'];
156
+ for (const val of fixture.verification_result_v2_schema.field_status_values) {
157
+ expect(validStatuses).toContain(val);
158
+ }
159
+ expect(fixture.verification_result_v2_schema.field_status_values).toHaveLength(validStatuses.length);
160
+ });
161
+ });
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // API contract conformance: SignEmail
165
+ // ---------------------------------------------------------------------------
166
+
167
+ describe('email conformance: signEmail API contract', () => {
168
+ const fixture = loadConformanceFixture();
169
+
170
+ afterEach(() => {
171
+ vi.restoreAllMocks();
172
+ });
173
+
174
+ it('sends POST to correct path with message/rfc822 content-type', async () => {
175
+ let gotMethod = '';
176
+ let gotPath = '';
177
+ let gotContentType = '';
178
+
179
+ vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => {
180
+ const u = new URL(url);
181
+ gotMethod = init?.method ?? 'GET';
182
+ gotPath = u.pathname;
183
+ gotContentType = (init?.headers as Record<string, string>)?.['Content-Type'] ?? '';
184
+ return Promise.resolve({
185
+ ok: true,
186
+ status: 200,
187
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
188
+ });
189
+ }));
190
+
191
+ const client = await makeClient('https://mock.hai.ai');
192
+ await client.signEmail('raw email');
193
+
194
+ expect(gotMethod).toBe(fixture.api_contracts.sign_email.method);
195
+ expect(gotPath).toBe(fixture.api_contracts.sign_email.path);
196
+ expect(gotContentType).toBe(fixture.api_contracts.sign_email.request_content_type);
197
+ });
198
+ });
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // API contract conformance: VerifyEmail
202
+ // ---------------------------------------------------------------------------
203
+
204
+ describe('email conformance: verifyEmail API contract', () => {
205
+ const fixture = loadConformanceFixture();
206
+
207
+ afterEach(() => {
208
+ vi.restoreAllMocks();
209
+ });
210
+
211
+ it('sends POST to correct path with message/rfc822 content-type', async () => {
212
+ let gotMethod = '';
213
+ let gotPath = '';
214
+ let gotContentType = '';
215
+
216
+ vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => {
217
+ const u = new URL(url);
218
+ gotMethod = init?.method ?? 'GET';
219
+ gotPath = u.pathname;
220
+ gotContentType = (init?.headers as Record<string, string>)?.['Content-Type'] ?? '';
221
+ return Promise.resolve({
222
+ ok: true,
223
+ status: 200,
224
+ json: () => Promise.resolve(fixture.mock_verify_response.json),
225
+ });
226
+ }));
227
+
228
+ const client = await makeClient('https://mock.hai.ai');
229
+ await client.verifyEmail('raw email');
230
+
231
+ expect(gotMethod).toBe(fixture.api_contracts.verify_email.method);
232
+ expect(gotPath).toBe(fixture.api_contracts.verify_email.path);
233
+ expect(gotContentType).toBe(fixture.api_contracts.verify_email.request_content_type);
234
+ });
235
+ });
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // API contract conformance: SendEmail excluded fields
239
+ // ---------------------------------------------------------------------------
240
+
241
+ describe('email conformance: sendEmail excluded fields', () => {
242
+ const fixture = loadConformanceFixture();
243
+
244
+ afterEach(() => {
245
+ vi.restoreAllMocks();
246
+ });
247
+
248
+ it('does not send client-side signing fields', async () => {
249
+ let gotBody: Record<string, unknown> = {};
250
+
251
+ vi.stubGlobal('fetch', vi.fn().mockImplementation((_url: string, init?: RequestInit) => {
252
+ if (init?.body) {
253
+ gotBody = JSON.parse(init.body as string) as Record<string, unknown>;
254
+ }
255
+ return Promise.resolve({
256
+ ok: true,
257
+ status: 200,
258
+ json: () => Promise.resolve({ message_id: 'msg-conf', status: 'sent' }),
259
+ });
260
+ }));
261
+
262
+ const client = await makeClient('https://mock.hai.ai');
263
+ await client.sendEmail({ to: 'bob@hai.ai', subject: 'Test', body: 'Body' });
264
+
265
+ for (const excluded of fixture.api_contracts.send_email.excluded_fields) {
266
+ expect(gotBody).not.toHaveProperty(excluded);
267
+ }
268
+ });
269
+ });
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // Error type conformance
273
+ // ---------------------------------------------------------------------------
274
+
275
+ describe('email conformance: error types', () => {
276
+ it('all email error sentinel classes exist', () => {
277
+ expect(EmailNotActiveError).toBeDefined();
278
+ expect(RecipientNotFoundError).toBeDefined();
279
+ expect(RateLimitedError).toBeDefined();
280
+
281
+ // Verify they are constructable
282
+ const e1 = new EmailNotActiveError('test');
283
+ const e2 = new RecipientNotFoundError('test');
284
+ const e3 = new RateLimitedError('test');
285
+ expect(e1.message).toBe('test');
286
+ expect(e2.message).toBe('test');
287
+ expect(e3.message).toBe('test');
288
+ });
289
+ });
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Live integration tests for HAI email CRUD operations.
3
+ *
4
+ * Gated behind HAI_LIVE_TEST=1. Requires a running HAI API at
5
+ * HAI_URL (defaults to http://localhost:3000) backed by Stalwart.
6
+ *
7
+ * Run:
8
+ * HAI_LIVE_TEST=1 HAI_URL=http://localhost:3000 npx vitest run tests/email-integration.test.ts
9
+ */
10
+
11
+ import { describe, it, expect, beforeAll } from 'vitest';
12
+ import { HaiClient } from '../src/index.js';
13
+ import { generateTestKeypair as generateKeypair } from './setup.js';
14
+ import type { SendEmailResult, EmailMessage, EmailStatus } from '../src/types.js';
15
+
16
+ const LIVE = process.env.HAI_LIVE_TEST === '1';
17
+ const API_URL = process.env.HAI_URL || 'http://localhost:3000';
18
+
19
+ /** Small helper to wait for async email delivery to settle. */
20
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
21
+
22
+ describe.skipIf(!LIVE)('Email integration (live API)', () => {
23
+ let client: HaiClient;
24
+ const agentName = `node-integ-${Date.now()}`;
25
+ let sentMessageId: string;
26
+ let replyMessageId: string;
27
+ const subject = `node-integ-test-${Date.now()}`;
28
+ const body = 'Hello from Node integration test!';
29
+
30
+ // -------------------------------------------------------------------------
31
+ // Setup: register agent + claim username to provision @hai.ai email
32
+ // -------------------------------------------------------------------------
33
+
34
+ beforeAll(async () => {
35
+ // 1. Generate fresh Ed25519 keypair.
36
+ const keypair = generateKeypair();
37
+
38
+ // 2. Build client from credentials (no jacs.config.json needed).
39
+ client = await HaiClient.fromCredentials(agentName, keypair.privateKeyPem, {
40
+ url: API_URL,
41
+ privateKeyPassphrase: 'keygen-password',
42
+ });
43
+
44
+ // 3. Register the agent with the local API (bootstrap registration).
45
+ const ownerEmail = process.env.HAI_OWNER_EMAIL || 'jonathan@hai.io';
46
+ const result = await client.register({
47
+ description: 'Node SDK email integration test agent',
48
+ ownerEmail,
49
+ });
50
+
51
+ expect(result.success).toBe(true);
52
+ expect(result.jacsId).toBeTruthy();
53
+ expect(result.agentId).toBeTruthy();
54
+ console.log(`Registered agent: jacsId=${result.jacsId}, agentId=${result.agentId}`);
55
+
56
+ // 4. Claim a username to provision the @hai.ai email address.
57
+ const claim = await client.claimUsername(client.haiAgentId, agentName);
58
+ expect(claim.email).toContain('@hai.ai');
59
+ console.log(`Claimed username: ${claim.username}, email=${claim.email}`);
60
+ }, 30_000);
61
+
62
+ // -------------------------------------------------------------------------
63
+ // 1. Send email -> assert message_id
64
+ // -------------------------------------------------------------------------
65
+
66
+ it('should send an email and return a message_id', async () => {
67
+ const result: SendEmailResult = await client.sendEmail({
68
+ to: `${agentName}@hai.ai`,
69
+ subject,
70
+ body,
71
+ });
72
+
73
+ sentMessageId = result.messageId;
74
+ expect(sentMessageId).toBeTruthy();
75
+ expect(typeof sentMessageId).toBe('string');
76
+ expect(result.status).toBeTruthy();
77
+ console.log(`Sent email: messageId=${sentMessageId}, status=${result.status}`);
78
+
79
+ // Allow time for async delivery before subsequent tests.
80
+ await sleep(2000);
81
+ }, 15_000);
82
+
83
+ // -------------------------------------------------------------------------
84
+ // 2. List messages -> assert sent message appears
85
+ // -------------------------------------------------------------------------
86
+
87
+ it('should list messages including the sent message', async () => {
88
+ const messages: EmailMessage[] = await client.listMessages({ limit: 50 });
89
+ expect(messages.length).toBeGreaterThan(0);
90
+
91
+ // The sent message should appear in the list (either as outbound or
92
+ // delivered back to self as inbound).
93
+ const found = messages.some(
94
+ (m) => m.id === sentMessageId || m.subject === subject,
95
+ );
96
+ expect(found).toBe(true);
97
+ console.log(`Listed ${messages.length} messages; sent message found=${found}`);
98
+ });
99
+
100
+ // -------------------------------------------------------------------------
101
+ // 3. Get message -> assert subject/body match
102
+ // -------------------------------------------------------------------------
103
+
104
+ it('should get the message by id with matching subject and body', async () => {
105
+ const msg: EmailMessage = await client.getMessage(sentMessageId);
106
+ expect(msg.id).toBe(sentMessageId);
107
+ expect(msg.subject).toBe(subject);
108
+ expect(msg.bodyText).toContain(body);
109
+ expect(msg.direction).toBeTruthy();
110
+ expect(msg.createdAt).toBeTruthy();
111
+ console.log(`Got message: id=${msg.id}, subject=${msg.subject}`);
112
+ });
113
+
114
+ // -------------------------------------------------------------------------
115
+ // 4. Mark read / unread -> no error
116
+ // -------------------------------------------------------------------------
117
+
118
+ it('should mark a message as read without error', async () => {
119
+ await client.markRead(sentMessageId);
120
+
121
+ // Verify the read state persisted.
122
+ const msg = await client.getMessage(sentMessageId);
123
+ expect(msg.isRead).toBe(true);
124
+ console.log('Marked read; isRead=' + msg.isRead);
125
+ });
126
+
127
+ it('should mark a message as unread without error', async () => {
128
+ await client.markUnread(sentMessageId);
129
+
130
+ // Verify the unread state persisted.
131
+ const msg = await client.getMessage(sentMessageId);
132
+ expect(msg.isRead).toBe(false);
133
+ console.log('Marked unread; isRead=' + msg.isRead);
134
+ });
135
+
136
+ // -------------------------------------------------------------------------
137
+ // 5. Search -> assert message found
138
+ // -------------------------------------------------------------------------
139
+
140
+ it('should search messages and find the sent message', async () => {
141
+ const results: EmailMessage[] = await client.searchMessages({ query: subject });
142
+ expect(results.length).toBeGreaterThan(0);
143
+
144
+ const found = results.some(
145
+ (m) => m.id === sentMessageId || m.subject === subject,
146
+ );
147
+ expect(found).toBe(true);
148
+ console.log(`Search found ${results.length} results; target found=${found}`);
149
+ });
150
+
151
+ // -------------------------------------------------------------------------
152
+ // 6. Unread count -> assert returns number
153
+ // -------------------------------------------------------------------------
154
+
155
+ it('should return unread count as a number', async () => {
156
+ const count: number = await client.getUnreadCount();
157
+ expect(typeof count).toBe('number');
158
+ expect(count).toBeGreaterThanOrEqual(0);
159
+ console.log(`Unread count: ${count}`);
160
+ });
161
+
162
+ // -------------------------------------------------------------------------
163
+ // 7. Email status -> assert returns status
164
+ // -------------------------------------------------------------------------
165
+
166
+ it('should return email status with expected fields', async () => {
167
+ const status: EmailStatus = await client.getEmailStatus();
168
+ expect(status.email).toBeTruthy();
169
+ expect(status.email).toContain('@hai.ai');
170
+ expect(status.status).toBeTruthy();
171
+ expect(typeof status.dailyLimit).toBe('number');
172
+ expect(typeof status.dailyUsed).toBe('number');
173
+ expect(typeof status.messagesSentTotal).toBe('number');
174
+ console.log(
175
+ `Email status: email=${status.email}, status=${status.status}, tier=${status.tier}`,
176
+ );
177
+ });
178
+
179
+ // -------------------------------------------------------------------------
180
+ // 8. Reply -> assert reply message_id
181
+ // -------------------------------------------------------------------------
182
+
183
+ it('should reply to the sent message and return a reply message_id', async () => {
184
+ // reply() expects the internal message ID (used in URL path), not the
185
+ // RFC Message-ID. It fetches the original message, derives the sender
186
+ // and subject, then calls sendEmail with inReplyTo threading.
187
+ const result: SendEmailResult = await client.reply(
188
+ sentMessageId,
189
+ 'Reply from Node integration test!',
190
+ );
191
+
192
+ replyMessageId = result.messageId;
193
+ expect(replyMessageId).toBeTruthy();
194
+ expect(typeof replyMessageId).toBe('string');
195
+ expect(result.status).toBeTruthy();
196
+ console.log(`Reply sent: messageId=${replyMessageId}, status=${result.status}`);
197
+ }, 15_000);
198
+
199
+ // -------------------------------------------------------------------------
200
+ // 9. Delete -> assert no error
201
+ // -------------------------------------------------------------------------
202
+
203
+ it('should delete the sent message without error', async () => {
204
+ // deleteMessage resolves void on success; any throw means failure.
205
+ await client.deleteMessage(sentMessageId);
206
+ console.log(`Deleted message: ${sentMessageId}`);
207
+ });
208
+
209
+ // -------------------------------------------------------------------------
210
+ // 10. Get deleted -> assert error/404
211
+ // -------------------------------------------------------------------------
212
+
213
+ it('should throw when getting a deleted message', async () => {
214
+ await expect(client.getMessage(sentMessageId)).rejects.toThrow();
215
+ console.log('Confirmed deleted message returns error');
216
+ });
217
+ });