sendcraft-cli 1.0.0 → 1.2.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 +551 -0
- package/bin/sendcraft.js +26 -8
- package/lib/banner.js +53 -0
- package/lib/client.js +87 -1
- package/lib/commands/campaigns.js +16 -15
- package/lib/commands/config.js +23 -15
- package/lib/commands/domains.js +49 -45
- package/lib/commands/emails.js +35 -31
- package/lib/commands/keys.js +32 -28
- package/lib/commands/login.js +206 -0
- package/lib/commands/mcp.js +77 -0
- package/lib/commands/send.js +8 -7
- package/lib/commands/subscribers.js +20 -21
- package/lib/commands/warmup.js +40 -19
- package/lib/config.js +21 -1
- package/lib/interactive.js +410 -0
- package/lib/output.js +28 -6
- package/package.json +10 -2
package/lib/commands/keys.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const { Command } = require('commander');
|
|
2
|
-
const ora = require('ora');
|
|
3
2
|
const chalk = require('chalk');
|
|
3
|
+
const _grad = require('gradient-string'); const gradient = _grad.default || _grad;
|
|
4
4
|
const client = require('../client');
|
|
5
|
-
const { table, json, info, success, error } = require('../output');
|
|
5
|
+
const { table, json, info, success, error, spinner } = require('../output');
|
|
6
6
|
|
|
7
7
|
const cmd = new Command('keys').description('Manage API keys');
|
|
8
8
|
|
|
@@ -11,26 +11,26 @@ cmd
|
|
|
11
11
|
.description('List all API keys')
|
|
12
12
|
.option('--json', 'Output raw JSON')
|
|
13
13
|
.action(async (opts) => {
|
|
14
|
-
const
|
|
14
|
+
const sp = spinner('Fetching API keys…').start();
|
|
15
15
|
try {
|
|
16
16
|
const data = await client.get('/user/keys');
|
|
17
|
-
|
|
17
|
+
sp.succeed(chalk.dim('Loaded'));
|
|
18
18
|
if (opts.json) return json(data);
|
|
19
19
|
const keys = data.keys || [];
|
|
20
|
-
if (!keys.length) return info('No API keys
|
|
20
|
+
if (!keys.length) return info('No API keys. Run: ' + chalk.cyan('sendcraft keys create <name>'));
|
|
21
21
|
table(
|
|
22
|
-
['ID', 'Name', 'Key
|
|
22
|
+
['ID', 'Name', 'Key', 'Permissions', 'Last Used', 'Created'],
|
|
23
23
|
keys.map(k => [
|
|
24
|
-
String(k._id).slice(-8),
|
|
25
|
-
k.name,
|
|
26
|
-
k.maskedKey || '***',
|
|
27
|
-
k.permissions
|
|
28
|
-
k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleDateString() : 'Never',
|
|
29
|
-
k.createdAt
|
|
24
|
+
chalk.dim(String(k._id).slice(-8)),
|
|
25
|
+
chalk.bold(k.name),
|
|
26
|
+
chalk.dim(k.maskedKey || '***'),
|
|
27
|
+
k.permissions === 'full_access' ? chalk.green('full_access') : chalk.yellow('sending_access'),
|
|
28
|
+
k.lastUsedAt ? chalk.dim(new Date(k.lastUsedAt).toLocaleDateString()) : chalk.dim('Never'),
|
|
29
|
+
k.createdAt ? chalk.dim(new Date(k.createdAt).toLocaleDateString()) : '—',
|
|
30
30
|
])
|
|
31
31
|
);
|
|
32
32
|
} catch (err) {
|
|
33
|
-
|
|
33
|
+
sp.fail(chalk.red('Failed'));
|
|
34
34
|
error(err.message);
|
|
35
35
|
process.exit(1);
|
|
36
36
|
}
|
|
@@ -39,11 +39,11 @@ cmd
|
|
|
39
39
|
cmd
|
|
40
40
|
.command('create <name>')
|
|
41
41
|
.description('Create a new API key')
|
|
42
|
-
.option('--permissions <type>', 'full_access or sending_access', 'full_access')
|
|
43
|
-
.option('--domains <
|
|
42
|
+
.option('--permissions <type>', 'full_access (default) or sending_access', 'full_access')
|
|
43
|
+
.option('--domains <list>', 'Comma-separated allowed sender domains')
|
|
44
44
|
.option('--json', 'Output raw JSON')
|
|
45
45
|
.action(async (name, opts) => {
|
|
46
|
-
const
|
|
46
|
+
const sp = spinner(`Creating key "${chalk.cyan(name)}"…`).start();
|
|
47
47
|
try {
|
|
48
48
|
const body = {
|
|
49
49
|
name,
|
|
@@ -51,18 +51,22 @@ cmd
|
|
|
51
51
|
allowedDomains: opts.domains ? opts.domains.split(',').map(d => d.trim()) : undefined,
|
|
52
52
|
};
|
|
53
53
|
const data = await client.post('/user/keys', body);
|
|
54
|
-
|
|
54
|
+
sp.succeed(chalk.dim('Created'));
|
|
55
55
|
if (opts.json) return json(data);
|
|
56
|
+
|
|
56
57
|
const key = data.key;
|
|
57
|
-
|
|
58
|
-
console.log(chalk.bold('
|
|
59
|
-
console.log(chalk.
|
|
60
|
-
console.log(`\nPermissions: ${key.permissions}`);
|
|
58
|
+
console.log('\n' + gradient(['#6366f1', '#10b981'])(' ─── New API Key ───') + '\n');
|
|
59
|
+
console.log(` ${chalk.bold('Name:')} ${chalk.cyan(key.name)}`);
|
|
60
|
+
console.log(` ${chalk.bold('Permissions:')} ${key.permissions === 'full_access' ? chalk.green('full_access') : chalk.yellow('sending_access')}`);
|
|
61
61
|
if (key.allowedDomains?.length) {
|
|
62
|
-
console.log(`
|
|
62
|
+
console.log(` ${chalk.bold('Domains:')} ${key.allowedDomains.join(', ')}`);
|
|
63
63
|
}
|
|
64
|
+
console.log(`\n ${chalk.bold.yellow('Key (save this — shown once only):')}`);
|
|
65
|
+
console.log(` ${chalk.bgBlack.green.bold(' ' + (key.key || '—') + ' ')}\n`);
|
|
66
|
+
|
|
67
|
+
success('Store this key securely. It cannot be retrieved again.');
|
|
64
68
|
} catch (err) {
|
|
65
|
-
|
|
69
|
+
sp.fail(chalk.red('Failed'));
|
|
66
70
|
error(err.message);
|
|
67
71
|
process.exit(1);
|
|
68
72
|
}
|
|
@@ -70,17 +74,17 @@ cmd
|
|
|
70
74
|
|
|
71
75
|
cmd
|
|
72
76
|
.command('revoke <keyId>')
|
|
73
|
-
.description('
|
|
77
|
+
.description('Permanently revoke an API key')
|
|
74
78
|
.option('--json', 'Output raw JSON')
|
|
75
79
|
.action(async (keyId, opts) => {
|
|
76
|
-
const
|
|
80
|
+
const sp = spinner(`Revoking key ${chalk.dim(keyId.slice(-8))}…`).start();
|
|
77
81
|
try {
|
|
78
82
|
const data = await client.delete(`/user/keys/${keyId}`);
|
|
79
|
-
|
|
83
|
+
sp.succeed(chalk.dim('Revoked'));
|
|
80
84
|
if (opts.json) return json(data);
|
|
81
|
-
success(`Key ${keyId} revoked.`);
|
|
85
|
+
success(`Key ${chalk.dim(keyId)} permanently revoked.`);
|
|
82
86
|
} catch (err) {
|
|
83
|
-
|
|
87
|
+
sp.fail(chalk.red('Failed'));
|
|
84
88
|
error(err.message);
|
|
85
89
|
process.exit(1);
|
|
86
90
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sendcraft login
|
|
3
|
+
*
|
|
4
|
+
* Two flows:
|
|
5
|
+
* 1. Browser — opens the dashboard settings page so the user can copy their API key
|
|
6
|
+
* 2. Email + Password — calls /auth/login, then auto-creates a "CLI" API key
|
|
7
|
+
*
|
|
8
|
+
* URLs are derived from the configured base URL — no domains hardcoded.
|
|
9
|
+
*/
|
|
10
|
+
const { Command } = require('commander');
|
|
11
|
+
const prompts = require('prompts');
|
|
12
|
+
const chalk = require('chalk');
|
|
13
|
+
const { load, save, getWebUrl, CONFIG_FILE } = require('../config');
|
|
14
|
+
const client = require('../client');
|
|
15
|
+
const { success, error, info, warn, spinner } = require('../output');
|
|
16
|
+
const { sendcraftGradient } = require('../banner');
|
|
17
|
+
|
|
18
|
+
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** Open a URL in the system's default browser (cross-platform). */
|
|
21
|
+
function openBrowser(url) {
|
|
22
|
+
const { execSync } = require('child_process');
|
|
23
|
+
try {
|
|
24
|
+
const cmd =
|
|
25
|
+
process.platform === 'darwin' ? `open "${url}"` :
|
|
26
|
+
process.platform === 'win32' ? `start "" "${url}"` :
|
|
27
|
+
`xdg-open "${url}"`;
|
|
28
|
+
execSync(cmd, { stdio: 'ignore' });
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── browser flow ─────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
async function browserFlow() {
|
|
38
|
+
const webUrl = getWebUrl();
|
|
39
|
+
const settingsUrl = `${webUrl}/dashboard/settings`;
|
|
40
|
+
|
|
41
|
+
console.log('\n' + sendcraftGradient(' ✦ Browser Login\n'));
|
|
42
|
+
info(`Opening dashboard: ${chalk.cyan(settingsUrl)}`);
|
|
43
|
+
console.log(chalk.dim(' 1. Open the API Keys tab'));
|
|
44
|
+
console.log(chalk.dim(' 2. Create or copy an existing key'));
|
|
45
|
+
console.log(chalk.dim(' 3. Paste it below\n'));
|
|
46
|
+
|
|
47
|
+
const opened = openBrowser(settingsUrl);
|
|
48
|
+
if (!opened) {
|
|
49
|
+
warn('Could not open browser automatically.');
|
|
50
|
+
console.log(` Open manually: ${chalk.cyan(settingsUrl)}\n`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { apiKey } = await prompts(
|
|
54
|
+
{
|
|
55
|
+
type: 'password',
|
|
56
|
+
name: 'apiKey',
|
|
57
|
+
message: 'Paste your API key here',
|
|
58
|
+
validate: (v) => (v && v.length > 10) ? true : 'Enter a valid API key',
|
|
59
|
+
},
|
|
60
|
+
{ onCancel: () => { error('Cancelled.'); process.exit(0); } }
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
if (!apiKey) return;
|
|
64
|
+
|
|
65
|
+
// Quick verify the key works
|
|
66
|
+
const sp = spinner('Verifying key…').start();
|
|
67
|
+
try {
|
|
68
|
+
// Store temporarily to use the authenticated client
|
|
69
|
+
const cfg = load();
|
|
70
|
+
cfg.api_key = apiKey;
|
|
71
|
+
save(cfg);
|
|
72
|
+
|
|
73
|
+
await client.get('/auth/me');
|
|
74
|
+
sp.succeed(chalk.dim('Verified'));
|
|
75
|
+
success(`Logged in! Key stored at ${chalk.dim(CONFIG_FILE)}`);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
// Restore old key if verification fails
|
|
78
|
+
const cfg = load();
|
|
79
|
+
if (cfg.api_key === apiKey) delete cfg.api_key;
|
|
80
|
+
save(cfg);
|
|
81
|
+
sp.fail(chalk.red('Invalid key'));
|
|
82
|
+
error(e.message);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── email+password flow ───────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
async function apiFlow() {
|
|
89
|
+
console.log('\n' + sendcraftGradient(' ✦ Sign In\n'));
|
|
90
|
+
|
|
91
|
+
const answers = await prompts(
|
|
92
|
+
[
|
|
93
|
+
{
|
|
94
|
+
type: 'text',
|
|
95
|
+
name: 'email',
|
|
96
|
+
message: 'Email address',
|
|
97
|
+
validate: (v) => v && v.includes('@') ? true : 'Enter a valid email',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
type: 'password',
|
|
101
|
+
name: 'password',
|
|
102
|
+
message: 'Password',
|
|
103
|
+
validate: (v) => v && v.length >= 4 ? true : 'Enter your password',
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
{ onCancel: () => { error('Cancelled.'); process.exit(0); } }
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
if (!answers.email || !answers.password) return;
|
|
110
|
+
|
|
111
|
+
const sp = spinner('Signing in…').start();
|
|
112
|
+
let token, userName;
|
|
113
|
+
try {
|
|
114
|
+
const result = await client.postNoAuth('/auth/login', {
|
|
115
|
+
email: answers.email,
|
|
116
|
+
password: answers.password,
|
|
117
|
+
});
|
|
118
|
+
token = result.token;
|
|
119
|
+
userName = result.user?.name || answers.email;
|
|
120
|
+
sp.succeed(chalk.dim('Authenticated'));
|
|
121
|
+
} catch (e) {
|
|
122
|
+
sp.fail(chalk.red('Login failed'));
|
|
123
|
+
error(e.message === 'Invalid credentials'
|
|
124
|
+
? 'Wrong email or password. Try again.'
|
|
125
|
+
: e.message);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Auto-create a CLI API key using the JWT
|
|
130
|
+
const sp2 = spinner('Creating CLI API key…').start();
|
|
131
|
+
try {
|
|
132
|
+
const result = await client.postWithToken('/user/keys', {
|
|
133
|
+
name: 'CLI',
|
|
134
|
+
permissions: 'full_access',
|
|
135
|
+
}, token);
|
|
136
|
+
|
|
137
|
+
const apiKey = result.key?.key;
|
|
138
|
+
if (!apiKey) throw new Error('No key returned from server');
|
|
139
|
+
|
|
140
|
+
sp2.succeed(chalk.dim('API key created'));
|
|
141
|
+
|
|
142
|
+
const cfg = load();
|
|
143
|
+
cfg.api_key = apiKey;
|
|
144
|
+
save(cfg);
|
|
145
|
+
|
|
146
|
+
console.log('\n ' + chalk.bold.green(`Welcome, ${userName}!`));
|
|
147
|
+
success(`Logged in! Key stored at ${chalk.dim(CONFIG_FILE)}`);
|
|
148
|
+
console.log(
|
|
149
|
+
'\n ' + chalk.dim('Tip: run ') + chalk.cyan('sendcraft config show') +
|
|
150
|
+
chalk.dim(' to confirm your setup.\n')
|
|
151
|
+
);
|
|
152
|
+
} catch (e) {
|
|
153
|
+
sp2.fail(chalk.red('Could not create API key'));
|
|
154
|
+
error(e.message);
|
|
155
|
+
info(`You can create a key manually at ${chalk.cyan(getWebUrl() + '/dashboard/settings')}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── command ──────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
const cmd = new Command('login')
|
|
162
|
+
.description('Log in to SendCraft and save your API key')
|
|
163
|
+
.option('--browser', 'Open dashboard in browser to copy an API key')
|
|
164
|
+
.option('--api', 'Log in with email and password via the API')
|
|
165
|
+
.action(async (opts) => {
|
|
166
|
+
if (opts.browser) return browserFlow();
|
|
167
|
+
if (opts.api) return apiFlow();
|
|
168
|
+
return runInteractive();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
/** Exported so the interactive TUI can call it directly */
|
|
172
|
+
async function runInteractive() {
|
|
173
|
+
console.log('\n' + sendcraftGradient(' ✦ SendCraft Login\n'));
|
|
174
|
+
|
|
175
|
+
const { method } = await prompts(
|
|
176
|
+
{
|
|
177
|
+
type: 'select',
|
|
178
|
+
name: 'method',
|
|
179
|
+
message: 'How would you like to log in?',
|
|
180
|
+
choices: [
|
|
181
|
+
{
|
|
182
|
+
title: '🌐 ' + chalk.bold('Browser') +
|
|
183
|
+
chalk.dim(' — open dashboard, copy your API key'),
|
|
184
|
+
value: 'browser',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
title: '🔐 ' + chalk.bold('Email & Password') +
|
|
188
|
+
chalk.dim(' — sign in and auto-generate a CLI key'),
|
|
189
|
+
value: 'api',
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
title: chalk.dim('✕ Back'),
|
|
193
|
+
value: 'cancel',
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
{ onCancel: () => null }
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
if (!method || method === 'cancel') return;
|
|
201
|
+
if (method === 'browser') return browserFlow();
|
|
202
|
+
if (method === 'api') return apiFlow();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
cmd.runInteractive = runInteractive;
|
|
206
|
+
module.exports = cmd;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const { Command } = require('commander');
|
|
2
|
+
const chalk = require('chalk');
|
|
3
|
+
const _grad2 = require('gradient-string'); const gradient = _grad2.default || _grad2;
|
|
4
|
+
const _boxen = require('boxen'); const boxen = _boxen.default || _boxen;
|
|
5
|
+
const { success, info } = require('../output');
|
|
6
|
+
|
|
7
|
+
const cmd = new Command('mcp').description('MCP (Model Context Protocol) server setup for AI agents');
|
|
8
|
+
|
|
9
|
+
cmd
|
|
10
|
+
.command('info')
|
|
11
|
+
.description('Show how to configure the SendCraft MCP server in Claude / Cursor / etc.')
|
|
12
|
+
.action(() => {
|
|
13
|
+
console.log('\n' + gradient(['#6366f1', '#8b5cf6', '#ec4899'])(' ✦ SendCraft MCP Server') + '\n');
|
|
14
|
+
|
|
15
|
+
const apiKey = process.env.SENDCRAFT_API_KEY || '<your-api-key>';
|
|
16
|
+
|
|
17
|
+
const claudeConfig = JSON.stringify({
|
|
18
|
+
mcpServers: {
|
|
19
|
+
sendcraft: {
|
|
20
|
+
command: 'npx',
|
|
21
|
+
args: ['sendcraft-mcp'],
|
|
22
|
+
env: {
|
|
23
|
+
SENDCRAFT_API_KEY: apiKey,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
}, null, 2);
|
|
28
|
+
|
|
29
|
+
console.log(chalk.bold(' 1. Install the MCP server:'));
|
|
30
|
+
console.log(` ${chalk.cyan('npm install -g sendcraft-mcp')}\n`);
|
|
31
|
+
|
|
32
|
+
console.log(chalk.bold(' 2. Add to Claude Desktop config') + chalk.dim(' (~/Library/Application Support/Claude/claude_desktop_config.json):'));
|
|
33
|
+
console.log(boxen(claudeConfig, {
|
|
34
|
+
padding: 1,
|
|
35
|
+
margin: { left: 4 },
|
|
36
|
+
borderStyle: 'round',
|
|
37
|
+
borderColor: 'cyan',
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
console.log(chalk.bold(' 3. Available MCP tools:'));
|
|
41
|
+
const tools = [
|
|
42
|
+
['sendcraft_send_email', 'Send a transactional email'],
|
|
43
|
+
['sendcraft_batch_send', 'Send up to 100 emails at once'],
|
|
44
|
+
['sendcraft_list_emails', 'List sent emails'],
|
|
45
|
+
['sendcraft_get_stats', 'Get email statistics'],
|
|
46
|
+
['sendcraft_list_campaigns', 'List campaigns'],
|
|
47
|
+
['sendcraft_list_subscribers','List subscribers'],
|
|
48
|
+
['sendcraft_add_subscriber', 'Add a new subscriber'],
|
|
49
|
+
['sendcraft_list_domains', 'List verified sender domains'],
|
|
50
|
+
['sendcraft_list_api_keys', 'List API keys'],
|
|
51
|
+
];
|
|
52
|
+
tools.forEach(([name, desc]) => {
|
|
53
|
+
console.log(` ${chalk.green('•')} ${chalk.cyan(name)} ${chalk.dim('— ' + desc)}`);
|
|
54
|
+
});
|
|
55
|
+
console.log();
|
|
56
|
+
info(`Docs: ${chalk.underline('https://sendcraft.online/docs/mcp')}`);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
cmd
|
|
60
|
+
.command('install')
|
|
61
|
+
.description('Install the sendcraft-mcp package globally')
|
|
62
|
+
.action(() => {
|
|
63
|
+
const { execSync } = require('child_process');
|
|
64
|
+
const ora = require('ora');
|
|
65
|
+
const sp = ora({ text: 'Installing sendcraft-mcp…', spinner: 'dots', color: 'cyan' }).start();
|
|
66
|
+
try {
|
|
67
|
+
execSync('npm install -g sendcraft-mcp', { stdio: 'pipe' });
|
|
68
|
+
sp.succeed(chalk.dim('Installed'));
|
|
69
|
+
success('sendcraft-mcp installed! Run ' + chalk.cyan('sendcraft mcp info') + ' for config instructions.');
|
|
70
|
+
} catch (e) {
|
|
71
|
+
sp.fail(chalk.red('Install failed'));
|
|
72
|
+
console.error(e.stderr?.toString() || e.message);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
module.exports = cmd;
|
package/lib/commands/send.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const { Command } = require('commander');
|
|
2
|
-
const
|
|
2
|
+
const chalk = require('chalk');
|
|
3
3
|
const client = require('../client');
|
|
4
|
-
const { success, error,
|
|
4
|
+
const { success, error, spinner } = require('../output');
|
|
5
5
|
|
|
6
6
|
const cmd = new Command('send')
|
|
7
7
|
.description('Send a single transactional email')
|
|
@@ -17,7 +17,8 @@ const cmd = new Command('send')
|
|
|
17
17
|
error('Provide at least --html or --text');
|
|
18
18
|
process.exit(1);
|
|
19
19
|
}
|
|
20
|
-
|
|
20
|
+
|
|
21
|
+
const sp = spinner(`Sending to ${chalk.cyan(opts.to)}…`).start();
|
|
21
22
|
try {
|
|
22
23
|
const result = await client.post('/emails/send', {
|
|
23
24
|
toEmail: opts.to,
|
|
@@ -27,11 +28,11 @@ const cmd = new Command('send')
|
|
|
27
28
|
fromEmail: opts.from,
|
|
28
29
|
replyTo: opts.replyTo,
|
|
29
30
|
});
|
|
30
|
-
|
|
31
|
-
if (opts.json) return json(result);
|
|
32
|
-
success(`Email sent!
|
|
31
|
+
sp.succeed(chalk.dim('Request complete'));
|
|
32
|
+
if (opts.json) return require('../output').json(result);
|
|
33
|
+
success(`Email sent! ${chalk.dim('ID: ' + (result.emailId || result._id || '—'))}`);
|
|
33
34
|
} catch (err) {
|
|
34
|
-
|
|
35
|
+
sp.fail(chalk.red('Send failed'));
|
|
35
36
|
error(err.message);
|
|
36
37
|
process.exit(1);
|
|
37
38
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const { Command } = require('commander');
|
|
2
|
-
const
|
|
2
|
+
const chalk = require('chalk');
|
|
3
3
|
const client = require('../client');
|
|
4
|
-
const { table, json, colorStatus, info, success, error } = require('../output');
|
|
4
|
+
const { table, json, colorStatus, info, success, error, spinner } = require('../output');
|
|
5
5
|
|
|
6
6
|
const cmd = new Command('subscribers').description('Manage subscribers');
|
|
7
7
|
|
|
@@ -13,28 +13,28 @@ cmd
|
|
|
13
13
|
.option('--status <s>', 'Filter: active, pending, unsubscribed')
|
|
14
14
|
.option('--json', 'Output raw JSON')
|
|
15
15
|
.action(async (opts) => {
|
|
16
|
-
const
|
|
16
|
+
const sp = spinner('Fetching subscribers…').start();
|
|
17
17
|
try {
|
|
18
18
|
const params = new URLSearchParams({ page: opts.page, limit: opts.limit });
|
|
19
19
|
if (opts.status) params.set('status', opts.status);
|
|
20
20
|
const data = await client.get(`/subscribers?${params}`);
|
|
21
|
-
|
|
21
|
+
sp.succeed(chalk.dim('Loaded'));
|
|
22
22
|
if (opts.json) return json(data);
|
|
23
23
|
const subs = data.subscribers || [];
|
|
24
24
|
if (!subs.length) return info('No subscribers found.');
|
|
25
25
|
table(
|
|
26
26
|
['Email', 'Name', 'Status', 'Tags', 'Joined'],
|
|
27
27
|
subs.map(s => [
|
|
28
|
-
s.email,
|
|
29
|
-
[s.firstName, s.lastName].filter(Boolean).join(' ') || '—',
|
|
28
|
+
chalk.cyan(s.email),
|
|
29
|
+
[s.firstName, s.lastName].filter(Boolean).join(' ') || chalk.dim('—'),
|
|
30
30
|
colorStatus(s.status),
|
|
31
|
-
(s.tags || []).join(', ')
|
|
32
|
-
s.createdAt ? new Date(s.createdAt).toLocaleDateString() : '—',
|
|
31
|
+
(s.tags || []).length ? s.tags.map(t => chalk.magenta(t)).join(', ') : chalk.dim('—'),
|
|
32
|
+
s.createdAt ? chalk.dim(new Date(s.createdAt).toLocaleDateString()) : '—',
|
|
33
33
|
])
|
|
34
34
|
);
|
|
35
|
-
info(`Page ${opts.page} · ${subs.length} of ${data.total
|
|
35
|
+
info(`Page ${opts.page} · ${subs.length} of ${chalk.bold(data.total ?? '?')}`);
|
|
36
36
|
} catch (err) {
|
|
37
|
-
|
|
37
|
+
sp.fail(chalk.red('Failed'));
|
|
38
38
|
error(err.message);
|
|
39
39
|
process.exit(1);
|
|
40
40
|
}
|
|
@@ -42,15 +42,14 @@ cmd
|
|
|
42
42
|
|
|
43
43
|
cmd
|
|
44
44
|
.command('add <email>')
|
|
45
|
-
.description('Add a subscriber')
|
|
46
|
-
.
|
|
45
|
+
.description('Add a subscriber to a list')
|
|
46
|
+
.requiredOption('--list <listId>', 'Email list ID')
|
|
47
47
|
.option('--first-name <name>', 'First name')
|
|
48
48
|
.option('--last-name <name>', 'Last name')
|
|
49
49
|
.option('--tags <tags>', 'Comma-separated tags')
|
|
50
50
|
.option('--json', 'Output raw JSON')
|
|
51
51
|
.action(async (email, opts) => {
|
|
52
|
-
|
|
53
|
-
const spinner = ora('Adding…').start();
|
|
52
|
+
const sp = spinner(`Adding ${chalk.cyan(email)}…`).start();
|
|
54
53
|
try {
|
|
55
54
|
const data = await client.post('/subscribers/add', {
|
|
56
55
|
email,
|
|
@@ -59,11 +58,11 @@ cmd
|
|
|
59
58
|
lastName: opts.lastName,
|
|
60
59
|
tags: opts.tags ? opts.tags.split(',').map(t => t.trim()) : undefined,
|
|
61
60
|
});
|
|
62
|
-
|
|
61
|
+
sp.succeed(chalk.dim('Done'));
|
|
63
62
|
if (opts.json) return json(data);
|
|
64
|
-
success(
|
|
63
|
+
success(`${chalk.cyan(email)} added to list.`);
|
|
65
64
|
} catch (err) {
|
|
66
|
-
|
|
65
|
+
sp.fail(chalk.red('Failed'));
|
|
67
66
|
error(err.message);
|
|
68
67
|
process.exit(1);
|
|
69
68
|
}
|
|
@@ -74,14 +73,14 @@ cmd
|
|
|
74
73
|
.description('Remove a subscriber by ID')
|
|
75
74
|
.option('--json', 'Output raw JSON')
|
|
76
75
|
.action(async (id, opts) => {
|
|
77
|
-
const
|
|
76
|
+
const sp = spinner(`Removing ${chalk.dim(id.slice(-8))}…`).start();
|
|
78
77
|
try {
|
|
79
78
|
const data = await client.delete(`/subscribers/${id}`);
|
|
80
|
-
|
|
79
|
+
sp.succeed(chalk.dim('Done'));
|
|
81
80
|
if (opts.json) return json(data);
|
|
82
|
-
success(`Subscriber
|
|
81
|
+
success(`Subscriber removed.`);
|
|
83
82
|
} catch (err) {
|
|
84
|
-
|
|
83
|
+
sp.fail(chalk.red('Failed'));
|
|
85
84
|
error(err.message);
|
|
86
85
|
process.exit(1);
|
|
87
86
|
}
|
package/lib/commands/warmup.js
CHANGED
|
@@ -1,52 +1,73 @@
|
|
|
1
1
|
const { Command } = require('commander');
|
|
2
|
-
const ora = require('ora');
|
|
3
2
|
const chalk = require('chalk');
|
|
3
|
+
const _grad = require('gradient-string'); const gradient = _grad.default || _grad;
|
|
4
4
|
const client = require('../client');
|
|
5
|
-
const { table, json, error } = require('../output');
|
|
5
|
+
const { table, json, error, spinner } = require('../output');
|
|
6
6
|
|
|
7
7
|
const cmd = new Command('warmup').description('View SMTP IP warmup status');
|
|
8
8
|
|
|
9
9
|
cmd
|
|
10
10
|
.option('--json', 'Output raw JSON')
|
|
11
11
|
.action(async (opts) => {
|
|
12
|
-
const
|
|
12
|
+
const sp = spinner('Checking warmup status…').start();
|
|
13
13
|
try {
|
|
14
14
|
const data = await client.get('/smtp/warmup');
|
|
15
|
-
|
|
15
|
+
sp.succeed(chalk.dim('Done'));
|
|
16
|
+
|
|
16
17
|
if (opts.json) return json(data);
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
console.log();
|
|
19
20
|
|
|
20
21
|
if (data.isWarmedUp) {
|
|
21
|
-
|
|
22
|
+
const g = gradient(['#10b981', '#3b82f6']);
|
|
23
|
+
console.log(g.multiline(
|
|
24
|
+
' ██╗ ██╗ █████╗ ██████╗ ███╗ ███╗███████╗██████╗ \n' +
|
|
25
|
+
' ██║ ██║██╔══██╗██╔══██╗████╗ ████║██╔════╝██╔══██╗\n' +
|
|
26
|
+
' ██║ █╗ ██║███████║██████╔╝██╔████╔██║█████╗ ██║ ██║\n' +
|
|
27
|
+
' ██║███╗██║██╔══██║██╔══██╗██║╚██╔╝██║██╔══╝ ██║ ██║\n' +
|
|
28
|
+
' ╚███╔███╔╝██║ ██║██║ ██║██║ ╚═╝ ██║███████╗██████╔╝\n' +
|
|
29
|
+
' ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═════╝ '
|
|
30
|
+
));
|
|
31
|
+
console.log(' ' + chalk.green.bold('✓ IP fully warmed up — no daily limits!\n'));
|
|
22
32
|
} else {
|
|
23
|
-
|
|
24
|
-
|
|
33
|
+
const pct = data.percentComplete || 0;
|
|
34
|
+
const bar = buildAnimatedBar(data.todayCount, data.dailyLimit, 30);
|
|
35
|
+
console.log(` ${chalk.bold.magenta('Warmup Day ' + data.warmupDay)}`);
|
|
36
|
+
console.log(` ${bar} ${chalk.cyan(data.todayCount)}${chalk.dim('/' + data.dailyLimit)} sent today`);
|
|
37
|
+
console.log(` ${chalk.dim(pct + '% of warmup complete')}\n`);
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
table(
|
|
28
41
|
['Field', 'Value'],
|
|
29
42
|
[
|
|
30
|
-
['Warmup Day',
|
|
31
|
-
['Daily Limit',
|
|
32
|
-
['Sent Today',
|
|
33
|
-
['Remaining Today', data.isWarmedUp ? '∞' : data.remainingToday],
|
|
34
|
-
['Warmed Up', data.isWarmedUp ? chalk.green('Yes') : chalk.yellow('No')],
|
|
35
|
-
['
|
|
43
|
+
['Warmup Day', chalk.bold(data.warmupDay)],
|
|
44
|
+
['Daily Limit', data.isWarmedUp ? chalk.green('∞ Unlimited') : chalk.yellow(String(data.dailyLimit))],
|
|
45
|
+
['Sent Today', chalk.cyan(String(data.todayCount))],
|
|
46
|
+
['Remaining Today', data.isWarmedUp ? '∞' : chalk.green(String(data.remainingToday))],
|
|
47
|
+
['Fully Warmed Up', data.isWarmedUp ? chalk.green('Yes ✓') : chalk.yellow('No')],
|
|
48
|
+
['Progress', (data.percentComplete ?? 0) + '%'],
|
|
36
49
|
]
|
|
37
50
|
);
|
|
38
51
|
} catch (err) {
|
|
39
|
-
|
|
52
|
+
sp.fail(chalk.red('Failed to fetch warmup status'));
|
|
40
53
|
error(err.message);
|
|
41
54
|
process.exit(1);
|
|
42
55
|
}
|
|
43
56
|
});
|
|
44
57
|
|
|
45
|
-
function
|
|
58
|
+
function buildAnimatedBar(count, limit, width) {
|
|
46
59
|
if (!limit) return '';
|
|
47
|
-
const
|
|
48
|
-
const filled = Math.round(
|
|
49
|
-
|
|
60
|
+
const ratio = Math.min(count / limit, 1);
|
|
61
|
+
const filled = Math.round(ratio * width);
|
|
62
|
+
const empty = width - filled;
|
|
63
|
+
|
|
64
|
+
// Gradient bar: green→yellow→red based on fill level
|
|
65
|
+
let barColor;
|
|
66
|
+
if (ratio < 0.5) barColor = chalk.green;
|
|
67
|
+
else if (ratio < 0.85) barColor = chalk.yellow;
|
|
68
|
+
else barColor = chalk.red;
|
|
69
|
+
|
|
70
|
+
return chalk.dim('[') + barColor('█'.repeat(filled)) + chalk.dim('░'.repeat(empty)) + chalk.dim(']');
|
|
50
71
|
}
|
|
51
72
|
|
|
52
73
|
module.exports = cmd;
|
package/lib/config.js
CHANGED
|
@@ -30,4 +30,24 @@ function getBaseUrl() {
|
|
|
30
30
|
return process.env.SENDCRAFT_BASE_URL || load().base_url || 'https://api.sendcraft.online/api';
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Derives the web dashboard URL from the API base URL.
|
|
35
|
+
* e.g. "https://api.sendcraft.online/api" → "https://sendcraft.online"
|
|
36
|
+
* "http://localhost:3000/api" → "http://localhost:3000"
|
|
37
|
+
* "https://myinstance.com/api" → "https://myinstance.com"
|
|
38
|
+
*/
|
|
39
|
+
function getWebUrl() {
|
|
40
|
+
const stored = load().web_url;
|
|
41
|
+
if (stored) return stored;
|
|
42
|
+
try {
|
|
43
|
+
const { URL } = require('url');
|
|
44
|
+
const parsed = new URL(getBaseUrl());
|
|
45
|
+
// Strip leading "api." subdomain if present
|
|
46
|
+
const host = parsed.hostname.replace(/^api\./, '');
|
|
47
|
+
return `${parsed.protocol}//${host}`;
|
|
48
|
+
} catch {
|
|
49
|
+
return 'https://sendcraft.online';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { load, save, getApiKey, getBaseUrl, getWebUrl, CONFIG_FILE };
|