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.
- package/README.md +210 -0
- package/circleinbox-cli.cjs +1626 -0
- package/lib/cli/cli-fixes.test.cjs +242 -0
- package/package.json +39 -0
|
@@ -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
|
+
}
|