circleinbox 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.
@@ -0,0 +1,1626 @@
1
+ #!/usr/bin/env node
2
+ // CircleInbox CLI — Email infrastructure for agents and developers
3
+ // https://circleinbox.com
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+ const crypto = require('crypto');
9
+
10
+ const CLI_VERSION = '0.1.0';
11
+ const HOME_DIR = os.homedir();
12
+ const CONFIG_DIR = path.join(HOME_DIR, '.circleinbox');
13
+ const DEFAULT_API_URL = 'https://circleinbox.com/api/v1';
14
+
15
+ // ============================================================================
16
+ // Color helpers
17
+ // ============================================================================
18
+
19
+ const c = {
20
+ red: (text) => '\x1b[0;31m' + text + '\x1b[0m',
21
+ green: (text) => '\x1b[0;32m' + text + '\x1b[0m',
22
+ yellow: (text) => '\x1b[1;33m' + text + '\x1b[0m',
23
+ blue: (text) => '\x1b[0;34m' + text + '\x1b[0m',
24
+ purple: (text) => '\x1b[0;35m' + text + '\x1b[0m',
25
+ cyan: (text) => '\x1b[0;36m' + text + '\x1b[0m',
26
+ bold: (text) => '\x1b[1m' + text + '\x1b[0m',
27
+ dim: (text) => '\x1b[2m' + text + '\x1b[0m',
28
+ };
29
+
30
+ // ============================================================================
31
+ // Flag parser
32
+ // ============================================================================
33
+
34
+ function parseFlags(args) {
35
+ const flags = {};
36
+ const positional = [];
37
+
38
+ for (let i = 0; i < args.length; i++) {
39
+ const arg = args[i];
40
+ if (arg.startsWith('--')) {
41
+ const key = arg.slice(2);
42
+ // Check if next arg exists and is not a flag
43
+ if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
44
+ flags[key] = args[i + 1];
45
+ i++;
46
+ } else {
47
+ flags[key] = true;
48
+ }
49
+ } else if (arg.startsWith('-') && arg.length === 2) {
50
+ const key = arg.slice(1);
51
+ if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
52
+ flags[key] = args[i + 1];
53
+ i++;
54
+ } else {
55
+ flags[key] = true;
56
+ }
57
+ } else {
58
+ positional.push(arg);
59
+ }
60
+ }
61
+
62
+ return { flags, positional };
63
+ }
64
+
65
+ // ============================================================================
66
+ // Output helpers
67
+ // ============================================================================
68
+
69
+ let globalFlags = {};
70
+
71
+ function output(data) {
72
+ if (globalFlags.json) {
73
+ console.log(JSON.stringify(data, null, 2));
74
+ }
75
+ }
76
+
77
+ function log(...args) {
78
+ if (!globalFlags.quiet) {
79
+ console.log(...args);
80
+ }
81
+ }
82
+
83
+ function info(msg) { log(c.blue('i'), msg); }
84
+ function success(msg) { log(c.green('✓'), msg); }
85
+ function warn(msg) { log(c.yellow('!'), msg); }
86
+ function error(msg) { console.error(c.red('✗'), msg); }
87
+
88
+ // ============================================================================
89
+ // Table formatter
90
+ // ============================================================================
91
+
92
+ function table(headers, rows) {
93
+ if (globalFlags.json) return;
94
+ if (rows.length === 0) {
95
+ log(c.dim(' (no results)'));
96
+ return;
97
+ }
98
+
99
+ // Calculate column widths
100
+ const widths = headers.map((h, i) => {
101
+ const maxData = rows.reduce((max, row) => Math.max(max, String(row[i] || '').length), 0);
102
+ return Math.max(h.length, maxData);
103
+ });
104
+
105
+ // Header
106
+ const headerLine = headers.map((h, i) => h.padEnd(widths[i])).join(' ');
107
+ log(c.bold(headerLine));
108
+ log(c.dim(widths.map(w => '─'.repeat(w)).join('──')));
109
+
110
+ // Rows
111
+ for (const row of rows) {
112
+ const line = row.map((cell, i) => String(cell || '').padEnd(widths[i])).join(' ');
113
+ log(line);
114
+ }
115
+ }
116
+
117
+ // ============================================================================
118
+ // Config manager
119
+ // ============================================================================
120
+
121
+ function ensureConfigDir() {
122
+ if (!fs.existsSync(CONFIG_DIR)) {
123
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
124
+ }
125
+ }
126
+
127
+ function loadConfig() {
128
+ const configPath = path.join(CONFIG_DIR, 'config.json');
129
+ try {
130
+ return JSON.parse(fs.readFileSync(configPath, 'utf8'));
131
+ } catch {
132
+ return { apiUrl: DEFAULT_API_URL, outputFormat: 'pretty' };
133
+ }
134
+ }
135
+
136
+ function saveConfig(config) {
137
+ ensureConfigDir();
138
+ const configPath = path.join(CONFIG_DIR, 'config.json');
139
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
140
+ }
141
+
142
+ // ============================================================================
143
+ // Credential management (AES-256-GCM)
144
+ // ============================================================================
145
+
146
+ const CREDENTIALS_PATH = path.join(CONFIG_DIR, 'credentials');
147
+ const KEY_PATH = path.join(CONFIG_DIR, '.key');
148
+ const KEY_LENGTH = 32;
149
+ const IV_LENGTH = 12;
150
+
151
+ function getEncryptionKey() {
152
+ // Try macOS keychain first
153
+ if (process.platform === 'darwin') {
154
+ try {
155
+ const { execSync } = require('child_process');
156
+ const key = execSync(
157
+ 'security find-generic-password -a circleinbox -s encryption-key -w 2>/dev/null',
158
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
159
+ ).trim();
160
+ if (key && key.length >= 64) {
161
+ return Buffer.from(key.slice(0, 64), 'hex');
162
+ }
163
+ } catch {
164
+ // Keychain entry doesn't exist, fall through
165
+ }
166
+ }
167
+
168
+ // Fallback: file-based key
169
+ try {
170
+ const keyData = JSON.parse(fs.readFileSync(KEY_PATH, 'utf8'));
171
+ if (keyData.derivedKey) {
172
+ return Buffer.from(keyData.derivedKey, 'hex');
173
+ }
174
+ } catch {
175
+ // Key doesn't exist, create one
176
+ }
177
+
178
+ // Generate new key
179
+ ensureConfigDir();
180
+ const key = crypto.randomBytes(KEY_LENGTH);
181
+ fs.writeFileSync(KEY_PATH, JSON.stringify({ derivedKey: key.toString('hex') }), { mode: 0o600 });
182
+
183
+ // Try to store in macOS keychain
184
+ if (process.platform === 'darwin') {
185
+ try {
186
+ const { execSync } = require('child_process');
187
+ execSync(
188
+ `security add-generic-password -a circleinbox -s encryption-key -w "${key.toString('hex')}" -U 2>/dev/null`,
189
+ { stdio: 'ignore' }
190
+ );
191
+ } catch {
192
+ // Keychain unavailable, file fallback is fine
193
+ }
194
+ }
195
+
196
+ return key;
197
+ }
198
+
199
+ function encryptCredentials(data) {
200
+ const key = getEncryptionKey();
201
+ const iv = crypto.randomBytes(IV_LENGTH);
202
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
203
+
204
+ const plaintext = JSON.stringify(data);
205
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
206
+ const tag = cipher.getAuthTag();
207
+
208
+ return JSON.stringify({
209
+ iv: iv.toString('hex'),
210
+ tag: tag.toString('hex'),
211
+ data: encrypted.toString('hex'),
212
+ });
213
+ }
214
+
215
+ function decryptCredentials() {
216
+ try {
217
+ const raw = fs.readFileSync(CREDENTIALS_PATH, 'utf8');
218
+ const { iv, tag, data } = JSON.parse(raw);
219
+
220
+ const key = getEncryptionKey();
221
+ const decipher = crypto.createDecipheriv(
222
+ 'aes-256-gcm',
223
+ key,
224
+ Buffer.from(iv, 'hex')
225
+ );
226
+ decipher.setAuthTag(Buffer.from(tag, 'hex'));
227
+
228
+ const decrypted = Buffer.concat([
229
+ decipher.update(Buffer.from(data, 'hex')),
230
+ decipher.final(),
231
+ ]);
232
+
233
+ return JSON.parse(decrypted.toString('utf8'));
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+
239
+ function saveCredentials(apiKey) {
240
+ ensureConfigDir();
241
+ const encrypted = encryptCredentials({ apiKey });
242
+ fs.writeFileSync(CREDENTIALS_PATH, encrypted, { mode: 0o600 });
243
+ }
244
+
245
+ function loadApiKey() {
246
+ const creds = decryptCredentials();
247
+ return creds?.apiKey || process.env.CIRCLEINBOX_API_KEY || null;
248
+ }
249
+
250
+ // ============================================================================
251
+ // API client
252
+ // ============================================================================
253
+
254
+ class ApiClient {
255
+ constructor(baseUrl, apiKey) {
256
+ this.baseUrl = baseUrl;
257
+ this.apiKey = apiKey;
258
+ }
259
+
260
+ async request(method, path, body, extraHeaders) {
261
+ const url = `${this.baseUrl}${path}`;
262
+ const headers = {
263
+ 'Authorization': `Bearer ${this.apiKey}`,
264
+ 'Content-Type': 'application/json',
265
+ ...extraHeaders,
266
+ };
267
+
268
+ let lastError;
269
+ for (let attempt = 0; attempt < 3; attempt++) {
270
+ try {
271
+ const controller = new AbortController();
272
+ const timeoutId = setTimeout(() => controller.abort(), 30000);
273
+ let response;
274
+ try {
275
+ response = await fetch(url, {
276
+ method,
277
+ headers,
278
+ body: body ? JSON.stringify(body) : undefined,
279
+ signal: controller.signal,
280
+ });
281
+ } finally {
282
+ clearTimeout(timeoutId);
283
+ }
284
+
285
+ const text = await response.text();
286
+ let data;
287
+ try {
288
+ data = JSON.parse(text);
289
+ } catch {
290
+ if (!response.ok) {
291
+ const err = new Error(`Request failed: ${response.status} ${response.statusText}`);
292
+ err.status = response.status;
293
+ err.code = 'INVALID_RESPONSE';
294
+ throw err;
295
+ }
296
+ // For successful responses with no/invalid JSON (e.g. 204), return empty object
297
+ data = {};
298
+ }
299
+
300
+ if (!response.ok) {
301
+ // Retry on 429 or 5xx
302
+ if ((response.status === 429 || response.status >= 500) && attempt < 2) {
303
+ const delay = Math.pow(2, attempt) * 1000 * (0.5 + Math.random());
304
+ await new Promise(r => setTimeout(r, delay));
305
+ continue;
306
+ }
307
+
308
+ const errMsg = data.error?.message || `Request failed: ${response.status}`;
309
+ const err = new Error(errMsg);
310
+ err.code = data.error?.code || 'UNKNOWN';
311
+ err.status = response.status;
312
+ throw err;
313
+ }
314
+
315
+ return data;
316
+ } catch (err) {
317
+ lastError = err;
318
+ if (err.status && err.status < 500 && err.status !== 429) throw err;
319
+ if (attempt < 2) {
320
+ const delay = Math.pow(2, attempt) * 1000 * (0.5 + Math.random());
321
+ await new Promise(r => setTimeout(r, delay));
322
+ }
323
+ }
324
+ }
325
+ throw lastError;
326
+ }
327
+
328
+ get(path) { return this.request('GET', path); }
329
+ post(path, body, headers) { return this.request('POST', path, body, headers); }
330
+ put(path, body) { return this.request('PUT', path, body); }
331
+ patch(path, body) { return this.request('PATCH', path, body); }
332
+ del(path) { return this.request('DELETE', path); }
333
+ }
334
+
335
+ function getClient() {
336
+ const apiKey = loadApiKey();
337
+ if (!apiKey) {
338
+ error('Not logged in. Run: circleinbox login');
339
+ process.exit(1);
340
+ }
341
+ const baseUrl = globalFlags.apiUrlOverride || loadConfig().apiUrl || DEFAULT_API_URL;
342
+ return new ApiClient(baseUrl, apiKey);
343
+ }
344
+
345
+ // ============================================================================
346
+ // Mailbox resolution helper
347
+ // ============================================================================
348
+
349
+ async function resolveMailbox(client, mailboxRef) {
350
+ // If it looks like a UUID, use directly
351
+ if (mailboxRef.includes('-') && !mailboxRef.includes('@')) {
352
+ return mailboxRef;
353
+ }
354
+
355
+ // Otherwise, look up by email address
356
+ const result = await client.get('/inboxes');
357
+ const matches = (result.data || []).filter(m =>
358
+ m.email === mailboxRef || m.email.startsWith(mailboxRef + '@')
359
+ );
360
+
361
+ if (matches.length === 0) {
362
+ throw new Error(`Mailbox not found: ${mailboxRef}`);
363
+ }
364
+ if (matches.length > 1) {
365
+ const emails = matches.map(m => m.email).join(', ');
366
+ throw new Error(`Ambiguous mailbox "${mailboxRef}" matches multiple: ${emails}. Use full email address.`);
367
+ }
368
+
369
+ return matches[0].id;
370
+ }
371
+
372
+ // ============================================================================
373
+ // Domain resolution helper
374
+ // ============================================================================
375
+
376
+ async function resolveDomain(client, domainRef) {
377
+ const result = await client.get('/domains');
378
+ const domain = result.data?.find(d =>
379
+ d.id === domainRef || d.domain === domainRef
380
+ );
381
+
382
+ if (!domain) {
383
+ throw new Error(`Domain not found: ${domainRef}`);
384
+ }
385
+
386
+ return domain;
387
+ }
388
+
389
+ // ============================================================================
390
+ // Commands: General
391
+ // ============================================================================
392
+
393
+ function commandVersion() {
394
+ console.log(`circleinbox v${CLI_VERSION}`);
395
+ }
396
+
397
+ function commandHelp() {
398
+ log(`
399
+ ${c.bold('CircleInbox CLI')} v${CLI_VERSION}
400
+ Email infrastructure for agents and developers
401
+
402
+ ${c.bold('USAGE')}
403
+ circleinbox <command> [options]
404
+
405
+ ${c.bold('GENERAL')}
406
+ login [--key <api-key>] Store API key (encrypted)
407
+ logout Remove stored credentials
408
+ status Show connection status and org info
409
+ version Show CLI version
410
+ help Show this help
411
+
412
+ ${c.bold('DOMAINS')}
413
+ domains list List all domains with DNS status
414
+ dns check <domain> Check DNS records for a domain
415
+
416
+ ${c.bold('INBOXES')}
417
+ inbox list List all mailboxes
418
+ inbox create <email> Create a new mailbox
419
+ inbox check <mailbox> [-l N] List emails in mailbox
420
+ inbox read <mailbox> <uid> Read a specific email
421
+ inbox delete <mailbox> Delete a mailbox
422
+
423
+ ${c.bold('SEND')}
424
+ send --from <email> --to <email> --subject "..." --text "..."
425
+
426
+ ${c.bold('ALIASES')}
427
+ alias list [--domain <d>] List aliases
428
+ alias create <addr> <dest> Create alias forwarding
429
+ alias update <id> <dest> Update alias destinations
430
+ alias delete <id> Delete an alias
431
+
432
+ ${c.bold('VAULT (Credentials)')}
433
+ vault list <mailbox> List stored credentials
434
+ vault store <mailbox> Store a new credential
435
+ vault get <mbx> <hash> Get credential with password
436
+ vault update <mbx> <hash> Update a credential
437
+ vault delete <mbx> <hash> Delete a credential
438
+
439
+ ${c.bold('PASSWORD RESET')}
440
+ reset <mailbox> <hash> Orchestrated password reset flow
441
+ reset <mbx> <hash> --clearauth ClearAuth direct API reset
442
+
443
+ ${c.bold('PROVISION')}
444
+ provision <domain> Full domain-to-mailbox setup
445
+
446
+ ${c.bold('GLOBAL FLAGS')}
447
+ --json Output raw JSON
448
+ --quiet Suppress decorative output
449
+ --api-url <url> Override API base URL
450
+ `);
451
+ }
452
+
453
+ // ============================================================================
454
+ // Commands: Login / Logout / Status
455
+ // ============================================================================
456
+
457
+ async function commandLogin(args) {
458
+ const { flags, positional } = parseFlags(args);
459
+ let apiKey = flags.key || positional[0];
460
+
461
+ if (!apiKey) {
462
+ // Read from stdin if available
463
+ const readline = require('readline');
464
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
465
+ apiKey = await new Promise((resolve) => {
466
+ rl.question('API Key (ci_live_...): ', (answer) => {
467
+ rl.close();
468
+ resolve(answer.replace(/[\x00-\x1F\x7F]/g, '').trim());
469
+ });
470
+ });
471
+ }
472
+
473
+ if (!apiKey) {
474
+ error('No API key provided');
475
+ process.exit(1);
476
+ }
477
+
478
+ if (!apiKey.startsWith('ci_live_')) {
479
+ error('Invalid API key format. Must start with ci_live_');
480
+ process.exit(1);
481
+ }
482
+
483
+ // Test the key
484
+ info('Verifying API key...');
485
+ const config = loadConfig();
486
+ const client = new ApiClient(config.apiUrl || DEFAULT_API_URL, apiKey);
487
+
488
+ try {
489
+ const result = await client.get('/domains');
490
+ saveCredentials(apiKey);
491
+ success(`Logged in. ${result.data?.length || 0} domain(s) accessible.`);
492
+ } catch (err) {
493
+ error(`API key verification failed: ${err.message}`);
494
+ process.exit(1);
495
+ }
496
+ }
497
+
498
+ async function commandLogout() {
499
+ try {
500
+ if (fs.existsSync(CREDENTIALS_PATH)) {
501
+ fs.unlinkSync(CREDENTIALS_PATH);
502
+ }
503
+ if (fs.existsSync(KEY_PATH)) {
504
+ fs.unlinkSync(KEY_PATH);
505
+ }
506
+
507
+ // Remove from macOS keychain
508
+ if (process.platform === 'darwin') {
509
+ try {
510
+ const { execSync } = require('child_process');
511
+ execSync('security delete-generic-password -a circleinbox -s encryption-key 2>/dev/null', { stdio: 'ignore' });
512
+ } catch {
513
+ // Not in keychain, that's fine
514
+ }
515
+ }
516
+
517
+ success('Logged out. Credentials removed.');
518
+ } catch (err) {
519
+ error(`Logout failed: ${err.message}`);
520
+ }
521
+ }
522
+
523
+ async function commandStatus() {
524
+ const apiKey = loadApiKey();
525
+ const config = loadConfig();
526
+
527
+ log(c.bold('CircleInbox Status'));
528
+ log('');
529
+ log(` API URL: ${config.apiUrl || DEFAULT_API_URL}`);
530
+ log(` Logged in: ${apiKey ? c.green('yes') : c.red('no')}`);
531
+
532
+ if (!apiKey) {
533
+ log('');
534
+ log(` Run ${c.cyan('circleinbox login')} to get started.`);
535
+ return;
536
+ }
537
+
538
+ try {
539
+ const client = new ApiClient(config.apiUrl || DEFAULT_API_URL, apiKey);
540
+ const [domains, inboxes] = await Promise.all([
541
+ client.get('/domains'),
542
+ client.get('/inboxes'),
543
+ ]);
544
+
545
+ log(` Domains: ${domains.data?.length || 0}`);
546
+ log(` Mailboxes: ${inboxes.data?.length || 0}`);
547
+
548
+ if (globalFlags.json) {
549
+ output({
550
+ apiUrl: config.apiUrl || DEFAULT_API_URL,
551
+ loggedIn: true,
552
+ domains: domains.data?.length || 0,
553
+ mailboxes: inboxes.data?.length || 0,
554
+ });
555
+ }
556
+ } catch (err) {
557
+ warn(`Could not reach API: ${err.message}`);
558
+ }
559
+ }
560
+
561
+ // ============================================================================
562
+ // Commands: Domains & DNS
563
+ // ============================================================================
564
+
565
+ async function commandDomains(args) {
566
+ const client = getClient();
567
+ const result = await client.get('/domains');
568
+ const domains = result.data || [];
569
+
570
+ if (globalFlags.json) { output(result); return; }
571
+
572
+ log(c.bold(`\nDomains (${domains.length})\n`));
573
+
574
+ const check = (v) => v ? c.green('✓') : c.red('✗');
575
+ table(
576
+ ['Domain', 'Status', 'MX', 'SPF', 'DKIM', 'Mailcow'],
577
+ domains.map(d => [
578
+ d.domain,
579
+ d.status,
580
+ check(d.mxVerified),
581
+ check(d.spfVerified),
582
+ check(d.dkimVerified),
583
+ check(d.mailcowProvisioned),
584
+ ])
585
+ );
586
+ log('');
587
+ }
588
+
589
+ async function commandDns(args) {
590
+ const { positional } = parseFlags(args);
591
+ const sub = positional[0];
592
+
593
+ if (sub !== 'check' || !positional[1]) {
594
+ error('Usage: circleinbox dns check <domain>');
595
+ process.exit(1);
596
+ }
597
+
598
+ const domainName = positional[1];
599
+ const client = getClient();
600
+ const domain = await resolveDomain(client, domainName);
601
+
602
+ if (globalFlags.json) { output(domain); return; }
603
+
604
+ const check = (v) => v ? c.green('✓ verified') : c.red('✗ missing');
605
+
606
+ log(c.bold(`\nDNS Status: ${domain.domain}\n`));
607
+ log(` MX Record: ${check(domain.mxVerified)}`);
608
+ log(` SPF Record: ${check(domain.spfVerified)}`);
609
+ log(` DKIM Record: ${check(domain.dkimVerified)}`);
610
+ log(` DMARC Record: ${check(domain.dmarcVerified)}`);
611
+ log(` Mailcow: ${domain.mailcowProvisioned ? c.green('provisioned') : c.yellow('not provisioned')}`);
612
+
613
+ if (!domain.mxVerified || !domain.spfVerified || !domain.dkimVerified) {
614
+ log(c.bold('\nRequired DNS Records:'));
615
+ if (!domain.mxVerified) {
616
+ log(` MX ${domain.domain} → mail.circleinbox.com (priority 10)`);
617
+ }
618
+ if (!domain.spfVerified) {
619
+ log(` TXT ${domain.domain} → "v=spf1 include:mail.circleinbox.com ~all"`);
620
+ }
621
+ if (!domain.dkimVerified) {
622
+ log(` CNAME dkim._domainkey.${domain.domain} → dkim._domainkey.mail.circleinbox.com`);
623
+ }
624
+ if (!domain.dmarcVerified) {
625
+ log(` TXT _dmarc.${domain.domain} → "v=DMARC1; p=quarantine; rua=mailto:dmarc@${domain.domain}"`);
626
+ }
627
+ }
628
+ log('');
629
+ }
630
+
631
+ // ============================================================================
632
+ // Commands: Inbox
633
+ // ============================================================================
634
+
635
+ async function commandInbox(args) {
636
+ const { flags, positional } = parseFlags(args);
637
+ const sub = positional[0];
638
+
639
+ switch (sub) {
640
+ case 'list':
641
+ return inboxList();
642
+ case 'create':
643
+ return inboxCreate(positional.slice(1), flags);
644
+ case 'check':
645
+ return inboxCheck(positional.slice(1), flags);
646
+ case 'read':
647
+ return inboxRead(positional.slice(1), flags);
648
+ case 'delete':
649
+ return inboxDelete(positional.slice(1));
650
+ default:
651
+ error('Usage: circleinbox inbox <list|create|check|read|delete>');
652
+ process.exit(1);
653
+ }
654
+ }
655
+
656
+ async function inboxList() {
657
+ const client = getClient();
658
+ const result = await client.get('/inboxes');
659
+ const inboxes = result.data || [];
660
+
661
+ if (globalFlags.json) { output(result); return; }
662
+
663
+ log(c.bold(`\nMailboxes (${inboxes.length})\n`));
664
+ table(
665
+ ['Email', 'Display Name', 'Type'],
666
+ inboxes.map(m => [m.email, m.displayName || '', m.type || 'regular'])
667
+ );
668
+ log('');
669
+ }
670
+
671
+ async function inboxCreate(positional, flags) {
672
+ if (!positional[0]) {
673
+ error('Usage: circleinbox inbox create <local>@<domain> [--display "Name"]');
674
+ process.exit(1);
675
+ }
676
+
677
+ const email = positional[0];
678
+ const atIndex = email.indexOf('@');
679
+ if (atIndex === -1) {
680
+ error('Invalid email format. Use: local@domain.com');
681
+ process.exit(1);
682
+ }
683
+
684
+ const localPart = email.slice(0, atIndex);
685
+ const domainName = email.slice(atIndex + 1);
686
+
687
+ const client = getClient();
688
+ const domain = await resolveDomain(client, domainName);
689
+
690
+ const body = {
691
+ domainId: domain.id,
692
+ localPart,
693
+ displayName: flags.display || flags.name || localPart,
694
+ };
695
+
696
+ if (flags.password) {
697
+ body.password = flags.password;
698
+ }
699
+
700
+ const result = await client.post('/inboxes', body);
701
+
702
+ if (globalFlags.json) { output(result); return; }
703
+
704
+ success(`Mailbox created: ${result.data.email}`);
705
+ log('');
706
+
707
+ if (result.data.password) {
708
+ log(c.yellow(' ⚠ SAVE THIS PASSWORD — it will not be shown again'));
709
+ log(` Password: ${c.bold(result.data.password)}`);
710
+ log('');
711
+ }
712
+
713
+ if (result.data.smtp) {
714
+ log(c.bold(' SMTP Connection:'));
715
+ log(` Host: ${result.data.smtp.host}`);
716
+ log(` Port: ${result.data.smtp.port}`);
717
+ log(` Security: ${result.data.smtp.security}`);
718
+ log(` Username: ${result.data.smtp.username}`);
719
+ }
720
+
721
+ if (result.data.imap) {
722
+ log(c.bold(' IMAP Connection:'));
723
+ log(` Host: ${result.data.imap.host}`);
724
+ log(` Port: ${result.data.imap.port}`);
725
+ log(` Security: ${result.data.imap.security}`);
726
+ log(` Username: ${result.data.imap.username}`);
727
+ }
728
+ log('');
729
+ }
730
+
731
+ async function inboxCheck(positional, flags) {
732
+ if (!positional[0]) {
733
+ error('Usage: circleinbox inbox check <mailbox> [--limit N]');
734
+ process.exit(1);
735
+ }
736
+
737
+ const client = getClient();
738
+ const mailboxId = await resolveMailbox(client, positional[0]);
739
+ const limit = flags.limit || flags.l || 20;
740
+
741
+ const result = await client.get(`/inboxes/${mailboxId}/emails?limit=${limit}`);
742
+
743
+ if (globalFlags.json) { output(result); return; }
744
+
745
+ const emails = result.data?.emails || [];
746
+ log(c.bold(`\nInbox: ${result.data?.inbox?.email || mailboxId} (${emails.length} emails)\n`));
747
+
748
+ table(
749
+ ['UID', 'From', 'Subject', 'Date', 'Read'],
750
+ emails.map(e => [
751
+ e.uid,
752
+ (e.from || '').slice(0, 30),
753
+ (e.subject || '').slice(0, 40),
754
+ e.date ? new Date(e.date).toLocaleDateString() : '',
755
+ e.isRead ? c.dim('read') : c.bold('NEW'),
756
+ ])
757
+ );
758
+ log('');
759
+ }
760
+
761
+ async function inboxRead(positional) {
762
+ if (!positional[0] || !positional[1]) {
763
+ error('Usage: circleinbox inbox read <mailbox> <uid>');
764
+ process.exit(1);
765
+ }
766
+
767
+ const client = getClient();
768
+ const mailboxId = await resolveMailbox(client, positional[0]);
769
+ const uid = positional[1];
770
+
771
+ const result = await client.get(`/inboxes/${mailboxId}/emails/${uid}`);
772
+
773
+ if (globalFlags.json) { output(result); return; }
774
+
775
+ const email = result.data;
776
+ log('');
777
+ log(c.bold(`Subject: ${email.subject || '(no subject)'}`));
778
+ log(`From: ${email.from}`);
779
+ log(`To: ${(email.to || []).join(', ')}`);
780
+ if (email.cc?.length) log(`CC: ${email.cc.join(', ')}`);
781
+ log(`Date: ${email.date}`);
782
+ log(c.dim('─'.repeat(60)));
783
+
784
+ // Prefer text body, strip basic HTML if only HTML available
785
+ const body = email.body?.text || (email.body?.html || '').replace(/<[^>]+>/g, '');
786
+ log(body);
787
+
788
+ if (email.attachments?.length) {
789
+ log('');
790
+ log(c.bold(`Attachments (${email.attachments.length}):`));
791
+ for (const att of email.attachments) {
792
+ log(` ${att.filename} (${att.contentType}, ${formatBytes(att.size)})`);
793
+ }
794
+ }
795
+ log('');
796
+ }
797
+
798
+ async function inboxDelete(positional) {
799
+ if (!positional[0]) {
800
+ error('Usage: circleinbox inbox delete <mailbox>');
801
+ process.exit(1);
802
+ }
803
+
804
+ const client = getClient();
805
+ const mailboxId = await resolveMailbox(client, positional[0]);
806
+
807
+ // Confirmation
808
+ const readline = require('readline');
809
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
810
+ const answer = await new Promise((resolve) => {
811
+ rl.question(c.yellow(`Delete mailbox ${positional[0]}? This cannot be undone. (y/N) `), (answer) => {
812
+ rl.close();
813
+ resolve(answer.replace(/[\x00-\x1F\x7F]/g, '').trim());
814
+ });
815
+ });
816
+
817
+ if (answer.toLowerCase() !== 'y') {
818
+ info('Cancelled.');
819
+ return;
820
+ }
821
+
822
+ await client.del(`/inboxes/${mailboxId}`);
823
+ success(`Mailbox deleted: ${positional[0]}`);
824
+ }
825
+
826
+ // ============================================================================
827
+ // Commands: Send
828
+ // ============================================================================
829
+
830
+ async function commandSend(args) {
831
+ const { flags } = parseFlags(args);
832
+
833
+ if (!flags.from || !flags.to || !flags.subject) {
834
+ error('Usage: circleinbox send --from <email> --to <email> --subject "..." --text "..."');
835
+ process.exit(1);
836
+ }
837
+
838
+ let text = flags.text;
839
+ let html = flags.html;
840
+
841
+ // Support reading from stdin
842
+ if (text === '-' || html === '-') {
843
+ const chunks = [];
844
+ for await (const chunk of process.stdin) {
845
+ chunks.push(chunk);
846
+ }
847
+ const stdinContent = Buffer.concat(chunks).toString('utf8');
848
+ if (text === '-') text = stdinContent;
849
+ if (html === '-') html = stdinContent;
850
+ }
851
+
852
+ if (!text && !html) {
853
+ error('Provide --text or --html body (use - to read from stdin)');
854
+ process.exit(1);
855
+ }
856
+
857
+ const client = getClient();
858
+ const body = {
859
+ from: { email: flags.from, name: flags['from-name'] },
860
+ to: flags.to,
861
+ subject: flags.subject,
862
+ };
863
+ if (text) body.text = text;
864
+ if (html) body.html = html;
865
+ if (flags.tags) body.tags = flags.tags.split(',');
866
+
867
+ const extraHeaders = {};
868
+ if (flags['idempotency-key']) {
869
+ extraHeaders['Idempotency-Key'] = flags['idempotency-key'];
870
+ }
871
+
872
+ const result = await client.post('/send', body, extraHeaders);
873
+
874
+ if (globalFlags.json) { output(result); return; }
875
+
876
+ success(`Email sent. Message ID: ${result.messageId}`);
877
+ if (result.rateLimit) {
878
+ log(c.dim(` Rate limit: ${result.rateLimit.remaining}/${result.rateLimit.limit} remaining`));
879
+ }
880
+ }
881
+
882
+ // ============================================================================
883
+ // Commands: Aliases
884
+ // ============================================================================
885
+
886
+ async function commandAlias(args) {
887
+ const { flags, positional } = parseFlags(args);
888
+ const sub = positional[0];
889
+
890
+ switch (sub) {
891
+ case 'list':
892
+ return aliasList(flags);
893
+ case 'create':
894
+ return aliasCreate(positional.slice(1), flags);
895
+ case 'update':
896
+ return aliasUpdate(positional.slice(1));
897
+ case 'delete':
898
+ return aliasDelete(positional.slice(1));
899
+ default:
900
+ error('Usage: circleinbox alias <list|create|update|delete>');
901
+ process.exit(1);
902
+ }
903
+ }
904
+
905
+ async function aliasList(flags) {
906
+ const client = getClient();
907
+ let path = '/aliases';
908
+
909
+ if (flags.domain) {
910
+ const domain = await resolveDomain(client, flags.domain);
911
+ path += `?domainId=${domain.id}`;
912
+ }
913
+
914
+ const result = await client.get(path);
915
+ const aliases = result.data || [];
916
+
917
+ if (globalFlags.json) { output(result); return; }
918
+
919
+ log(c.bold(`\nAliases (${aliases.length})\n`));
920
+ table(
921
+ ['Address', 'Destinations', 'Created'],
922
+ aliases.map(a => [
923
+ a.address,
924
+ (a.destinations || []).join(', '),
925
+ a.createdAt ? new Date(a.createdAt).toLocaleDateString() : '',
926
+ ])
927
+ );
928
+ log('');
929
+ }
930
+
931
+ async function aliasCreate(positional) {
932
+ if (!positional[0] || !positional[1]) {
933
+ error('Usage: circleinbox alias create <alias>@<domain> <dest1> [dest2...]');
934
+ process.exit(1);
935
+ }
936
+
937
+ const address = positional[0];
938
+ const destinations = positional.slice(1);
939
+ const atIndex = address.indexOf('@');
940
+ if (atIndex === -1) {
941
+ error('Invalid alias format. Use: local@domain.com');
942
+ process.exit(1);
943
+ }
944
+
945
+ const localPart = address.slice(0, atIndex);
946
+ const domainName = address.slice(atIndex + 1);
947
+
948
+ const client = getClient();
949
+ const domain = await resolveDomain(client, domainName);
950
+
951
+ const result = await client.post('/aliases', {
952
+ domainId: domain.id,
953
+ localPart,
954
+ destinations,
955
+ });
956
+
957
+ if (globalFlags.json) { output(result); return; }
958
+
959
+ success(`Alias created: ${address} → ${destinations.join(', ')}`);
960
+ }
961
+
962
+ async function aliasUpdate(positional) {
963
+ if (!positional[0] || !positional[1]) {
964
+ error('Usage: circleinbox alias update <aliasId> <dest1> [dest2...]');
965
+ process.exit(1);
966
+ }
967
+
968
+ const aliasId = positional[0];
969
+ const destinations = positional.slice(1);
970
+
971
+ const client = getClient();
972
+ const result = await client.patch(`/aliases/${aliasId}`, { destinations });
973
+
974
+ if (globalFlags.json) { output(result); return; }
975
+
976
+ success(`Alias updated: destinations → ${destinations.join(', ')}`);
977
+ }
978
+
979
+ async function aliasDelete(positional) {
980
+ if (!positional[0]) {
981
+ error('Usage: circleinbox alias delete <aliasId>');
982
+ process.exit(1);
983
+ }
984
+
985
+ const client = getClient();
986
+ await client.del(`/aliases/${positional[0]}`);
987
+ success(`Alias deleted: ${positional[0]}`);
988
+ }
989
+
990
+ // ============================================================================
991
+ // Commands: Vault (Credentials)
992
+ // ============================================================================
993
+
994
+ async function commandVault(args) {
995
+ const { flags, positional } = parseFlags(args);
996
+ const sub = positional[0];
997
+
998
+ switch (sub) {
999
+ case 'list':
1000
+ return vaultList(positional.slice(1), flags);
1001
+ case 'store':
1002
+ return vaultStore(positional.slice(1), flags);
1003
+ case 'get':
1004
+ return vaultGet(positional.slice(1));
1005
+ case 'update':
1006
+ return vaultUpdate(positional.slice(1), flags);
1007
+ case 'delete':
1008
+ return vaultDelete(positional.slice(1));
1009
+ default:
1010
+ error('Usage: circleinbox vault <list|store|get|update|delete>');
1011
+ process.exit(1);
1012
+ }
1013
+ }
1014
+
1015
+ async function vaultList(positional) {
1016
+ if (!positional[0]) {
1017
+ error('Usage: circleinbox vault list <mailbox>');
1018
+ process.exit(1);
1019
+ }
1020
+
1021
+ const client = getClient();
1022
+ const mailboxId = await resolveMailbox(client, positional[0]);
1023
+ const result = await client.get(`/credentials?mailboxId=${mailboxId}`);
1024
+ const creds = result.data || [];
1025
+
1026
+ if (globalFlags.json) { output(result); return; }
1027
+
1028
+ log(c.bold(`\nCredentials for ${positional[0]} (${creds.length})\n`));
1029
+ table(
1030
+ ['Service', 'URL', 'Username', 'Created'],
1031
+ creds.map(cr => [
1032
+ cr.serviceName || '',
1033
+ cr.serviceUrl || '',
1034
+ cr.email || cr.username || '',
1035
+ cr.createdAt ? new Date(cr.createdAt).toLocaleDateString() : '',
1036
+ ])
1037
+ );
1038
+ log('');
1039
+ }
1040
+
1041
+ async function vaultStore(positional, flags) {
1042
+ if (!positional[0]) {
1043
+ error('Usage: circleinbox vault store <mailbox> --service <url> --username <user> [--password <pass>] [--notes "..."]');
1044
+ process.exit(1);
1045
+ }
1046
+
1047
+ if (!flags.service || !flags.username) {
1048
+ error('Required: --service <url> --username <user>');
1049
+ process.exit(1);
1050
+ }
1051
+
1052
+ const client = getClient();
1053
+ const mailboxId = await resolveMailbox(client, positional[0]);
1054
+
1055
+ const body = {
1056
+ mailboxId,
1057
+ serviceUrl: flags.service,
1058
+ username: flags.username,
1059
+ };
1060
+ if (flags.password) body.password = flags.password;
1061
+ if (flags.name) body.serviceName = flags.name;
1062
+ if (flags.notes) body.notes = flags.notes;
1063
+
1064
+ const result = await client.post('/credentials', body);
1065
+
1066
+ if (globalFlags.json) { output(result); return; }
1067
+
1068
+ success(`Credential stored: ${result.data?.serviceName || flags.service}`);
1069
+ if (result.passwordGenerated) {
1070
+ warn('Password was auto-generated. Save it now:');
1071
+ }
1072
+ log(` Username: ${result.data?.username || result.data?.email}`);
1073
+ log(` Password: ${c.bold(result.data?.password || '(hidden)')}`);
1074
+ if (result.data?.notes) log(` Notes: ${result.data.notes}`);
1075
+ log('');
1076
+ }
1077
+
1078
+ async function vaultGet(positional) {
1079
+ if (!positional[0] || !positional[1]) {
1080
+ error('Usage: circleinbox vault get <mailbox> <serviceHash>');
1081
+ process.exit(1);
1082
+ }
1083
+
1084
+ const client = getClient();
1085
+ const mailboxId = await resolveMailbox(client, positional[0]);
1086
+ const result = await client.get(`/credentials/${positional[1]}?mailboxId=${mailboxId}`);
1087
+
1088
+ if (globalFlags.json) { output(result); return; }
1089
+
1090
+ const cred = result.data;
1091
+ log('');
1092
+ log(c.bold(cred.serviceName || 'Credential'));
1093
+ log(` URL: ${cred.serviceUrl}`);
1094
+ log(` Username: ${cred.username || cred.email}`);
1095
+ log(` Password: ${c.bold(cred.password)}`);
1096
+ if (cred.notes) log(` Notes: ${cred.notes}`);
1097
+ log(` Created: ${cred.createdAt}`);
1098
+ log(` Updated: ${cred.updatedAt}`);
1099
+ log('');
1100
+ }
1101
+
1102
+ async function vaultUpdate(positional, flags) {
1103
+ if (!positional[0] || !positional[1]) {
1104
+ error('Usage: circleinbox vault update <mailbox> <serviceHash> [--username <user>] [--password <pass>] [--notes "..."]');
1105
+ process.exit(1);
1106
+ }
1107
+
1108
+ const body = {};
1109
+ if (flags.username) body.username = flags.username;
1110
+ if (flags.password) body.password = flags.password;
1111
+ if (flags.notes !== undefined) body.notes = flags.notes;
1112
+
1113
+ if (Object.keys(body).length === 0) {
1114
+ error('Provide at least one of: --username, --password, --notes');
1115
+ process.exit(1);
1116
+ }
1117
+
1118
+ const client = getClient();
1119
+ const mailboxId = await resolveMailbox(client, positional[0]);
1120
+ const result = await client.put(`/credentials/${positional[1]}?mailboxId=${mailboxId}`, body);
1121
+
1122
+ if (globalFlags.json) { output(result); return; }
1123
+
1124
+ success(`Credential updated: ${result.data?.serviceName || positional[1]}`);
1125
+ }
1126
+
1127
+ async function vaultDelete(positional) {
1128
+ if (!positional[0] || !positional[1]) {
1129
+ error('Usage: circleinbox vault delete <mailbox> <serviceHash>');
1130
+ process.exit(1);
1131
+ }
1132
+
1133
+ const client = getClient();
1134
+ const mailboxId = await resolveMailbox(client, positional[0]);
1135
+ await client.del(`/credentials/${positional[1]}?mailboxId=${mailboxId}`);
1136
+ success(`Credential deleted: ${positional[1]}`);
1137
+ }
1138
+
1139
+ // ============================================================================
1140
+ // Commands: Password Reset
1141
+ // ============================================================================
1142
+
1143
+ async function commandReset(args) {
1144
+ const { flags, positional } = parseFlags(args);
1145
+
1146
+ if (!positional[0] || !positional[1]) {
1147
+ error('Usage: circleinbox reset <mailbox> <serviceHash> [--clearauth] [--password <new>]');
1148
+ process.exit(1);
1149
+ }
1150
+
1151
+ const client = getClient();
1152
+ const mailboxId = await resolveMailbox(client, positional[0]);
1153
+ const serviceHash = positional[1];
1154
+
1155
+ // Step 1: Look up credential
1156
+ info('Looking up credential...');
1157
+ const credResult = await client.get(`/credentials/${serviceHash}?mailboxId=${mailboxId}`);
1158
+ const cred = credResult.data;
1159
+
1160
+ log(` Service: ${cred.serviceName || cred.serviceUrl}`);
1161
+ log(` URL: ${cred.serviceUrl}`);
1162
+ log(` Username: ${cred.username || cred.email}`);
1163
+ log('');
1164
+
1165
+ if (flags.clearauth) {
1166
+ // ClearAuth direct API flow
1167
+ return resetClearAuth(client, mailboxId, serviceHash, cred, flags);
1168
+ }
1169
+
1170
+ // Refuse to send credentials over non-HTTPS connections
1171
+ const serviceUrl = cred.serviceUrl.replace(/\/$/, '');
1172
+ if (!serviceUrl.startsWith('https://')) {
1173
+ error('Refusing to send credentials over insecure HTTP. Service URL must use HTTPS.');
1174
+ process.exit(1);
1175
+ }
1176
+
1177
+ // Generic flow: instruct user to trigger reset manually
1178
+ log(c.bold('Step 1: Trigger password reset'));
1179
+ log(` Go to ${c.cyan(cred.serviceUrl)} and click "Forgot Password"`);
1180
+ log(` Enter: ${c.bold(cred.username || cred.email)}`);
1181
+ log('');
1182
+ log(c.bold('Step 2: Waiting for reset email...'));
1183
+
1184
+ // Poll inbox
1185
+ const resetEmail = await pollForResetEmail(client, mailboxId, cred.serviceUrl);
1186
+
1187
+ if (!resetEmail) {
1188
+ error('No reset email found after 5 minutes. Check manually.');
1189
+ process.exit(1);
1190
+ }
1191
+
1192
+ // Extract links
1193
+ log('');
1194
+ log(c.bold('Step 3: Reset links found:'));
1195
+ const links = extractResetLinks(resetEmail);
1196
+ links.forEach((link, i) => {
1197
+ log(` [${i + 1}] ${link}`);
1198
+ });
1199
+
1200
+ if (links.length === 0) {
1201
+ warn('No reset links found in email. Check manually.');
1202
+ log(c.dim(' Email body:'));
1203
+ const body = resetEmail.body?.text || (resetEmail.body?.html || '').replace(/<[^>]+>/g, '').slice(0, 500);
1204
+ log(c.dim(' ' + body));
1205
+ }
1206
+
1207
+ // Update vault
1208
+ log('');
1209
+ log(c.bold('Step 4: Update credential'));
1210
+ let newPassword = flags.password;
1211
+ if (!newPassword) {
1212
+ const readline = require('readline');
1213
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1214
+ newPassword = await new Promise((resolve) => {
1215
+ rl.question('New password (or press enter to skip): ', (answer) => {
1216
+ rl.close();
1217
+ resolve(answer.replace(/[\x00-\x1F\x7F]/g, '').trim());
1218
+ });
1219
+ });
1220
+ }
1221
+
1222
+ if (newPassword) {
1223
+ const now = new Date().toISOString().slice(0, 10);
1224
+ await client.put(`/credentials/${serviceHash}?mailboxId=${mailboxId}`, {
1225
+ password: newPassword,
1226
+ notes: `Password reset ${now}. ${cred.notes || ''}`.trim(),
1227
+ });
1228
+ success('Credential updated with new password.');
1229
+ } else {
1230
+ info('Skipped credential update.');
1231
+ }
1232
+ }
1233
+
1234
+ async function resetClearAuth(client, mailboxId, serviceHash, cred, flags) {
1235
+ const serviceUrl = cred.serviceUrl.replace(/\/$/, '');
1236
+ const username = cred.username || cred.email;
1237
+
1238
+ // Refuse to send credentials over non-HTTPS connections
1239
+ if (!serviceUrl.startsWith('https://')) {
1240
+ error('Refusing to send credentials over insecure HTTP. Service URL must use HTTPS.');
1241
+ process.exit(1);
1242
+ }
1243
+
1244
+ // Step 1: Request reset via ClearAuth API
1245
+ info(`Requesting reset from ${serviceUrl}/auth/request-reset ...`);
1246
+ try {
1247
+ const controller1 = new AbortController();
1248
+ const timeoutId1 = setTimeout(() => controller1.abort(), 30000);
1249
+ try {
1250
+ await fetch(`${serviceUrl}/auth/request-reset`, {
1251
+ method: 'POST',
1252
+ headers: { 'Content-Type': 'application/json' },
1253
+ body: JSON.stringify({ email: username }),
1254
+ signal: controller1.signal,
1255
+ });
1256
+ } finally {
1257
+ clearTimeout(timeoutId1);
1258
+ }
1259
+ success('Reset requested.');
1260
+ } catch (err) {
1261
+ error(`Failed to request reset: ${err.message}`);
1262
+ process.exit(1);
1263
+ }
1264
+
1265
+ // Step 2: Poll for email
1266
+ log(c.bold('Waiting for reset email...'));
1267
+ const resetEmail = await pollForResetEmail(client, mailboxId, serviceUrl);
1268
+
1269
+ if (!resetEmail) {
1270
+ error('No reset email found after 5 minutes.');
1271
+ process.exit(1);
1272
+ }
1273
+
1274
+ // Step 3: Extract token
1275
+ const body = resetEmail.body?.text || resetEmail.body?.html || '';
1276
+ const tokenMatch = body.match(/[?&]token=([a-zA-Z0-9._-]+)/);
1277
+ if (!tokenMatch) {
1278
+ error('Could not extract reset token from email.');
1279
+ const links = extractResetLinks(resetEmail);
1280
+ if (links.length) {
1281
+ log('Found links:');
1282
+ links.forEach(l => log(` ${l}`));
1283
+ }
1284
+ process.exit(1);
1285
+ }
1286
+
1287
+ const token = tokenMatch[1];
1288
+ info(`Token extracted: ${token.slice(0, 12)}...`);
1289
+
1290
+ // Step 4: Generate or use provided password
1291
+ const newPassword = flags.password || generatePassword();
1292
+ if (!flags.password) {
1293
+ info(`Generated password: ${c.bold(newPassword)}`);
1294
+ }
1295
+
1296
+ // Step 5: Complete reset via ClearAuth API
1297
+ info(`Completing reset at ${serviceUrl}/auth/reset-password ...`);
1298
+ const controller2 = new AbortController();
1299
+ const timeoutId2 = setTimeout(() => controller2.abort(), 30000);
1300
+ let resetResponse;
1301
+ try {
1302
+ resetResponse = await fetch(`${serviceUrl}/auth/reset-password`, {
1303
+ method: 'POST',
1304
+ headers: { 'Content-Type': 'application/json' },
1305
+ body: JSON.stringify({ token, password: newPassword }),
1306
+ signal: controller2.signal,
1307
+ });
1308
+ } finally {
1309
+ clearTimeout(timeoutId2);
1310
+ }
1311
+
1312
+ if (!resetResponse.ok) {
1313
+ error(`Reset failed: ${resetResponse.status}`);
1314
+ process.exit(1);
1315
+ }
1316
+
1317
+ success('Password reset complete.');
1318
+
1319
+ // Step 6: Update vault
1320
+ const now = new Date().toISOString().slice(0, 10);
1321
+ await client.put(`/credentials/${serviceHash}?mailboxId=${mailboxId}`, {
1322
+ password: newPassword,
1323
+ notes: `ClearAuth reset ${now}. ${cred.notes || ''}`.trim(),
1324
+ });
1325
+ success('Credential updated in vault.');
1326
+ }
1327
+
1328
+ async function pollForResetEmail(client, mailboxId, serviceUrl) {
1329
+ let serviceDomain = '';
1330
+ try { serviceDomain = new URL(serviceUrl).hostname; } catch {}
1331
+
1332
+ const maxAttempts = 30; // 5 minutes at 10s intervals
1333
+ let consecutiveErrors = 0;
1334
+ for (let i = 0; i < maxAttempts; i++) {
1335
+ if (i > 0) {
1336
+ await new Promise(r => setTimeout(r, 10000));
1337
+ }
1338
+ process.stdout.write('.');
1339
+
1340
+ try {
1341
+ const result = await client.get(`/inboxes/${mailboxId}/emails?limit=5`);
1342
+ consecutiveErrors = 0; // Reset on success
1343
+ const emails = result.data?.emails || [];
1344
+
1345
+ for (const email of emails) {
1346
+ const subject = (email.subject || '').toLowerCase();
1347
+ const from = (email.from || '').toLowerCase();
1348
+
1349
+ const isResetEmail =
1350
+ /reset|password|verify|confirm/i.test(subject) ||
1351
+ (serviceDomain && from.includes(serviceDomain));
1352
+
1353
+ if (isResetEmail) {
1354
+ // Fetch full email
1355
+ const full = await client.get(`/inboxes/${mailboxId}/emails/${email.uid}`);
1356
+ process.stdout.write('\n');
1357
+ success(`Found reset email: "${email.subject}"`);
1358
+ return full.data;
1359
+ }
1360
+ }
1361
+ } catch (err) {
1362
+ consecutiveErrors++;
1363
+ // Abort on auth errors — no point continuing
1364
+ if (err.code === 'AUTH_ERROR' || err.status === 401 || err.status === 403) {
1365
+ process.stdout.write('\n');
1366
+ error(`Authentication error while polling: ${err.message}`);
1367
+ return null;
1368
+ }
1369
+ // Abort after 5 consecutive errors (server likely down)
1370
+ if (consecutiveErrors >= 5) {
1371
+ process.stdout.write('\n');
1372
+ error(`Polling aborted after ${consecutiveErrors} consecutive errors: ${err.message}`);
1373
+ return null;
1374
+ }
1375
+ }
1376
+ }
1377
+
1378
+ process.stdout.write('\n');
1379
+ return null;
1380
+ }
1381
+
1382
+ function extractResetLinks(email) {
1383
+ const body = (email.body?.text || '') + ' ' + (email.body?.html || '');
1384
+ const urlPattern = /https?:\/\/[^\s"'<>]+(?:reset|token|confirm|password|verify)[^\s"'<>]*/gi;
1385
+ const matches = body.match(urlPattern) || [];
1386
+ return [...new Set(matches)];
1387
+ }
1388
+
1389
+ function generatePassword(length = 20) {
1390
+ const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
1391
+ const lower = 'abcdefghijklmnopqrstuvwxyz';
1392
+ const nums = '0123456789';
1393
+ const special = '!@#$%^&*_+-=';
1394
+ const all = upper + lower + nums + special;
1395
+
1396
+ const required = [
1397
+ upper[crypto.randomInt(upper.length)],
1398
+ lower[crypto.randomInt(lower.length)],
1399
+ nums[crypto.randomInt(nums.length)],
1400
+ special[crypto.randomInt(special.length)],
1401
+ ];
1402
+
1403
+ const rest = [];
1404
+ for (let i = 0; i < length - required.length; i++) {
1405
+ rest.push(all[crypto.randomInt(all.length)]);
1406
+ }
1407
+
1408
+ const chars = [...required, ...rest];
1409
+ for (let i = chars.length - 1; i > 0; i--) {
1410
+ const j = crypto.randomInt(i + 1);
1411
+ [chars[i], chars[j]] = [chars[j], chars[i]];
1412
+ }
1413
+
1414
+ return chars.join('');
1415
+ }
1416
+
1417
+ // ============================================================================
1418
+ // Commands: Provision
1419
+ // ============================================================================
1420
+
1421
+ async function commandProvision(args) {
1422
+ const { flags, positional } = parseFlags(args);
1423
+
1424
+ if (!positional[0]) {
1425
+ error('Usage: circleinbox provision <domain> [--local hello] [--display "Name"]');
1426
+ process.exit(1);
1427
+ }
1428
+
1429
+ const domainName = positional[0];
1430
+ const localPart = flags.local || 'hello';
1431
+ const displayName = flags.display || flags.name || localPart;
1432
+ const dryRun = flags['dry-run'] === true;
1433
+
1434
+ const client = getClient();
1435
+
1436
+ // Step 1: Check domain exists
1437
+ info(`Checking domain: ${domainName}...`);
1438
+ const domain = await resolveDomain(client, domainName);
1439
+
1440
+ log(` Status: ${domain.status}`);
1441
+ log(` Mailcow: ${domain.mailcowProvisioned ? c.green('provisioned') : c.yellow('not provisioned')}`);
1442
+
1443
+ if (!domain.mailcowProvisioned) {
1444
+ if (dryRun) {
1445
+ log(c.yellow('\n [dry-run] Would provision domain in Mailcow'));
1446
+ return;
1447
+ }
1448
+ info('Provisioning domain in Mailcow...');
1449
+ await client.post(`/domains/${domain.id}/setup-user-inboxes`);
1450
+ success('Domain provisioned.');
1451
+ }
1452
+
1453
+ // Step 2: Create mailbox
1454
+ const email = `${localPart}@${domainName}`;
1455
+ if (dryRun) {
1456
+ log(c.yellow(`\n [dry-run] Would create mailbox: ${email}`));
1457
+ return;
1458
+ }
1459
+
1460
+ info(`Creating mailbox: ${email}...`);
1461
+ const result = await client.post('/inboxes', {
1462
+ domainId: domain.id,
1463
+ localPart,
1464
+ displayName,
1465
+ });
1466
+
1467
+ success(`Mailbox created: ${result.data.email}`);
1468
+ log('');
1469
+
1470
+ if (result.data.password) {
1471
+ log(c.yellow(' ⚠ SAVE THIS PASSWORD — it will not be shown again'));
1472
+ log(` Password: ${c.bold(result.data.password)}`);
1473
+ }
1474
+
1475
+ if (result.data.smtp) {
1476
+ log('');
1477
+ log(c.bold(' SMTP:') + ` ${result.data.smtp.host}:${result.data.smtp.port} (${result.data.smtp.security})`);
1478
+ log(c.bold(' IMAP:') + ` ${result.data.imap.host}:${result.data.imap.port} (${result.data.imap.security})`);
1479
+ }
1480
+
1481
+ // Step 3: DNS check
1482
+ if (!domain.mxVerified || !domain.spfVerified || !domain.dkimVerified) {
1483
+ log('');
1484
+ warn('DNS records incomplete. Required:');
1485
+ if (!domain.mxVerified) log(` MX ${domainName} → mail.circleinbox.com (priority 10)`);
1486
+ if (!domain.spfVerified) log(` TXT ${domainName} → "v=spf1 include:mail.circleinbox.com ~all"`);
1487
+ if (!domain.dkimVerified) log(` CNAME dkim._domainkey.${domainName} → dkim._domainkey.mail.circleinbox.com`);
1488
+ }
1489
+
1490
+ log('');
1491
+ }
1492
+
1493
+ // ============================================================================
1494
+ // Utilities
1495
+ // ============================================================================
1496
+
1497
+ function formatBytes(bytes) {
1498
+ if (!bytes) return '0 B';
1499
+ const units = ['B', 'KB', 'MB', 'GB'];
1500
+ let i = 0;
1501
+ let size = bytes;
1502
+ while (size >= 1024 && i < units.length - 1) {
1503
+ size /= 1024;
1504
+ i++;
1505
+ }
1506
+ return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
1507
+ }
1508
+
1509
+ // ============================================================================
1510
+ // Main
1511
+ // ============================================================================
1512
+
1513
+ try {
1514
+ const allArgs = process.argv.slice(2);
1515
+ const { flags: gf, positional: topArgs } = parseFlags(allArgs);
1516
+
1517
+ // Extract global flags before routing
1518
+ globalFlags = {
1519
+ json: gf.json === true,
1520
+ quiet: gf.quiet === true,
1521
+ };
1522
+
1523
+ // Override API URL if provided (in-memory only, never persisted)
1524
+ if (gf['api-url']) {
1525
+ globalFlags.apiUrlOverride = gf['api-url'];
1526
+ }
1527
+
1528
+ const command = topArgs[0];
1529
+ const args = topArgs.slice(1);
1530
+
1531
+ // Find the command's actual index in allArgs by skipping global flags
1532
+ const GLOBAL_FLAGS_WITH_VALUE = new Set(['--api-url']);
1533
+ const GLOBAL_FLAGS_BOOLEAN = new Set(['--json', '--quiet']);
1534
+ let commandIndex = -1;
1535
+ for (let i = 0; i < allArgs.length; i++) {
1536
+ if (GLOBAL_FLAGS_WITH_VALUE.has(allArgs[i])) {
1537
+ i++; // skip the flag's value
1538
+ continue;
1539
+ }
1540
+ if (GLOBAL_FLAGS_BOOLEAN.has(allArgs[i])) {
1541
+ continue;
1542
+ }
1543
+ // First non-global-flag arg is the command
1544
+ commandIndex = i;
1545
+ break;
1546
+ }
1547
+
1548
+ // Slice from after the command and strip global flags
1549
+ const rawSubArgs = commandIndex >= 0 ? allArgs.slice(commandIndex + 1) : [];
1550
+ const subArgs = [];
1551
+ for (let i = 0; i < rawSubArgs.length; i++) {
1552
+ if (GLOBAL_FLAGS_BOOLEAN.has(rawSubArgs[i])) continue;
1553
+ if (GLOBAL_FLAGS_WITH_VALUE.has(rawSubArgs[i])) {
1554
+ i++; // skip the flag's value
1555
+ continue;
1556
+ }
1557
+ subArgs.push(rawSubArgs[i]);
1558
+ }
1559
+
1560
+ switch (command) {
1561
+ case 'version':
1562
+ case '--version':
1563
+ case '-v':
1564
+ commandVersion();
1565
+ break;
1566
+
1567
+ case 'help':
1568
+ case '--help':
1569
+ case '-h':
1570
+ case undefined:
1571
+ commandHelp();
1572
+ break;
1573
+
1574
+ case 'login':
1575
+ commandLogin(subArgs).catch(err => { error(err.message); process.exit(1); });
1576
+ break;
1577
+
1578
+ case 'logout':
1579
+ commandLogout().catch(err => { error(err.message); process.exit(1); });
1580
+ break;
1581
+
1582
+ case 'status':
1583
+ commandStatus().catch(err => { error(err.message); process.exit(1); });
1584
+ break;
1585
+
1586
+ case 'domains':
1587
+ commandDomains(subArgs).catch(err => { error(err.message); process.exit(1); });
1588
+ break;
1589
+
1590
+ case 'dns':
1591
+ commandDns(subArgs).catch(err => { error(err.message); process.exit(1); });
1592
+ break;
1593
+
1594
+ case 'inbox':
1595
+ commandInbox(subArgs).catch(err => { error(err.message); process.exit(1); });
1596
+ break;
1597
+
1598
+ case 'send':
1599
+ commandSend(subArgs).catch(err => { error(err.message); process.exit(1); });
1600
+ break;
1601
+
1602
+ case 'alias':
1603
+ commandAlias(subArgs).catch(err => { error(err.message); process.exit(1); });
1604
+ break;
1605
+
1606
+ case 'vault':
1607
+ commandVault(subArgs).catch(err => { error(err.message); process.exit(1); });
1608
+ break;
1609
+
1610
+ case 'reset':
1611
+ commandReset(subArgs).catch(err => { error(err.message); process.exit(1); });
1612
+ break;
1613
+
1614
+ case 'provision':
1615
+ commandProvision(subArgs).catch(err => { error(err.message); process.exit(1); });
1616
+ break;
1617
+
1618
+ default:
1619
+ error(`Unknown command: ${command}`);
1620
+ log(`Run ${c.cyan('circleinbox help')} for usage.`);
1621
+ process.exit(1);
1622
+ }
1623
+ } catch (err) {
1624
+ console.error(c.red('✗'), err.message);
1625
+ process.exit(1);
1626
+ }