@cakemail-org/cakemail-cli 1.5.0 → 2.0.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/.claude/settings.local.json +12 -0
- package/.env.example +40 -0
- package/.env.test.example +45 -0
- package/CHANGELOG.md +1031 -0
- package/README.md +319 -15
- package/audit-formats.js +128 -0
- package/cakemail.rb +20 -0
- package/dist/cli.js +27 -10
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +16 -6
- package/dist/client.js.map +1 -1
- package/dist/commands/account.js +1 -1
- package/dist/commands/account.js.map +1 -1
- package/dist/commands/attributes.js +1 -1
- package/dist/commands/attributes.js.map +1 -1
- package/dist/commands/campaigns.d.ts.map +1 -1
- package/dist/commands/campaigns.js +103 -8
- package/dist/commands/campaigns.js.map +1 -1
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +63 -4
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/contacts.d.ts.map +1 -1
- package/dist/commands/contacts.js +91 -12
- package/dist/commands/contacts.js.map +1 -1
- package/dist/commands/emails.js +1 -1
- package/dist/commands/emails.js.map +1 -1
- package/dist/commands/interests.d.ts +5 -0
- package/dist/commands/interests.d.ts.map +1 -0
- package/dist/commands/interests.js +172 -0
- package/dist/commands/interests.js.map +1 -0
- package/dist/commands/lists.d.ts.map +1 -1
- package/dist/commands/lists.js +6 -8
- package/dist/commands/lists.js.map +1 -1
- package/dist/commands/logs.d.ts +5 -0
- package/dist/commands/logs.d.ts.map +1 -0
- package/dist/commands/logs.js +237 -0
- package/dist/commands/logs.js.map +1 -0
- package/dist/commands/reports.js +1 -1
- package/dist/commands/reports.js.map +1 -1
- package/dist/commands/segments.js +1 -1
- package/dist/commands/segments.js.map +1 -1
- package/dist/commands/senders.d.ts.map +1 -1
- package/dist/commands/senders.js +11 -8
- package/dist/commands/senders.js.map +1 -1
- package/dist/commands/suppressed.js +1 -1
- package/dist/commands/suppressed.js.map +1 -1
- package/dist/commands/tags.d.ts +5 -0
- package/dist/commands/tags.d.ts.map +1 -0
- package/dist/commands/tags.js +124 -0
- package/dist/commands/tags.js.map +1 -0
- package/dist/commands/templates.js +1 -1
- package/dist/commands/templates.js.map +1 -1
- package/dist/commands/transactional-templates.d.ts +5 -0
- package/dist/commands/transactional-templates.d.ts.map +1 -0
- package/dist/commands/transactional-templates.js +354 -0
- package/dist/commands/transactional-templates.js.map +1 -0
- package/dist/commands/webhooks.js +1 -1
- package/dist/commands/webhooks.js.map +1 -1
- package/dist/utils/auth.d.ts +8 -1
- package/dist/utils/auth.d.ts.map +1 -1
- package/dist/utils/auth.js +39 -11
- package/dist/utils/auth.js.map +1 -1
- package/dist/utils/config-file.d.ts +7 -0
- package/dist/utils/config-file.d.ts.map +1 -1
- package/dist/utils/config-file.js +15 -0
- package/dist/utils/config-file.js.map +1 -1
- package/dist/utils/config.d.ts +2 -0
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/config.js +12 -4
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/errors.js +1 -1
- package/dist/utils/errors.js.map +1 -1
- package/dist/utils/list-defaults.d.ts +33 -0
- package/dist/utils/list-defaults.d.ts.map +1 -0
- package/dist/utils/list-defaults.js +52 -0
- package/dist/utils/list-defaults.js.map +1 -0
- package/dist/utils/output.d.ts.map +1 -1
- package/dist/utils/output.js +36 -13
- package/dist/utils/output.js.map +1 -1
- package/dist/utils/progress.d.ts.map +1 -1
- package/dist/utils/progress.js +32 -4
- package/dist/utils/progress.js.map +1 -1
- package/dist/utils/spinner.d.ts +17 -0
- package/dist/utils/spinner.d.ts.map +1 -0
- package/dist/utils/spinner.js +43 -0
- package/dist/utils/spinner.js.map +1 -0
- package/docs/DOCUMENTATION-STANDARD.md +1068 -0
- package/docs/README.md +161 -0
- package/docs/developer/ARCHITECTURE.md +516 -0
- package/docs/developer/AUTH.md +204 -0
- package/docs/developer/CONTRIBUTING.md +227 -0
- package/docs/developer/DOCUMENTATION_SUMMARY.md +346 -0
- package/docs/developer/PROJECT_INDEX.md +365 -0
- package/docs/planning/API_COVERAGE.md +1045 -0
- package/docs/planning/BACKLOG.md +1159 -0
- package/docs/planning/PROFILE_SYSTEM_TASKS.md +287 -0
- package/docs/planning/UX_IMPLEMENTATION_PLAN.md +691 -0
- package/docs/planning/archive/RELEASE_CHECKLIST_v1.3.0.md +332 -0
- package/docs/planning/archive/RELEASE_v1.3.0.md +428 -0
- package/docs/planning/archive/cakemail-cli-ux-improvements.md +438 -0
- package/docs/planning/cakemail-profile-system-plan.md +1121 -0
- package/docs/testing/AI_USER_SIMULATION_DESIGN.md +1342 -0
- package/docs/testing/KENOGAMI_BIDIRECTIONAL_FLOW.md +1517 -0
- package/docs/testing/KENOGAMI_TRUTH_RECONCILIATION_SYSTEM.md +1369 -0
- package/docs/user-manual/.obsidian/app.json +1 -0
- package/docs/user-manual/.obsidian/appearance.json +1 -0
- package/docs/user-manual/.obsidian/core-plugins.json +33 -0
- package/docs/user-manual/.obsidian/workspace.json +167 -0
- package/docs/user-manual/01-getting-started/01-installation.md +214 -0
- package/docs/user-manual/01-getting-started/02-quick-start.md +432 -0
- package/docs/user-manual/01-getting-started/03-authentication.md +448 -0
- package/docs/user-manual/01-getting-started/04-configuration.md +430 -0
- package/docs/user-manual/01-getting-started/05-output-formats.md +447 -0
- package/docs/user-manual/02-core-concepts/01-accounts.md +514 -0
- package/docs/user-manual/02-core-concepts/02-profile-system.md +771 -0
- package/docs/user-manual/02-core-concepts/03-smart-defaults.md +485 -0
- package/docs/user-manual/02-core-concepts/04-authentication-methods.md +435 -0
- package/docs/user-manual/02-core-concepts/05-pagination-filtering.md +600 -0
- package/docs/user-manual/02-core-concepts/06-error-handling.md +718 -0
- package/docs/user-manual/02-core-concepts/07-api-coverage.md +483 -0
- package/docs/user-manual/03-email-operations/01-senders.md +490 -0
- package/docs/user-manual/03-email-operations/02-templates.md +444 -0
- package/docs/user-manual/03-email-operations/03-transactional-emails.md +706 -0
- package/docs/user-manual/03-email-operations/04-email-tracking.md +407 -0
- package/docs/user-manual/04-campaign-management/01-campaigns-basics.md +394 -0
- package/docs/user-manual/04-campaign-management/02-campaign-scheduling.md +630 -0
- package/docs/user-manual/04-campaign-management/03-campaign-testing.md +997 -0
- package/docs/user-manual/04-campaign-management/04-campaign-lifecycle.md +709 -0
- package/docs/user-manual/04-campaign-management/05-campaign-links.md +934 -0
- package/docs/user-manual/05-contact-management/01-lists.md +836 -0
- package/docs/user-manual/05-contact-management/02-contacts.md +1035 -0
- package/docs/user-manual/05-contact-management/03-custom-attributes.md +788 -0
- package/docs/user-manual/05-contact-management/04-segments.md +1028 -0
- package/docs/user-manual/05-contact-management/05-contact-import-export.md +1031 -0
- package/docs/user-manual/06-analytics-reporting/01-campaign-analytics.md +867 -0
- package/docs/user-manual/06-analytics-reporting/02-account-reports.md +227 -0
- package/docs/user-manual/07-integrations/01-webhooks-integration.md +259 -0
- package/docs/user-manual/07-integrations/02-automation.md +326 -0
- package/docs/user-manual/08-advanced-usage/01-scripting-patterns.md +672 -0
- package/docs/user-manual/08-advanced-usage/02-bulk-operations.md +932 -0
- package/docs/user-manual/08-advanced-usage/03-ci-cd-integration.md +892 -0
- package/docs/user-manual/08-advanced-usage/04-performance-optimization.md +766 -0
- package/docs/user-manual/09-command-reference/01-config.md +776 -0
- package/docs/user-manual/09-command-reference/02-account.md +652 -0
- package/docs/user-manual/09-command-reference/03-lists.md +958 -0
- package/docs/user-manual/09-command-reference/04-contacts.md +1408 -0
- package/docs/user-manual/09-command-reference/05-attributes.md +617 -0
- package/docs/user-manual/09-command-reference/06-segments.md +894 -0
- package/docs/user-manual/09-command-reference/07-senders.md +803 -0
- package/docs/user-manual/09-command-reference/08-templates.md +818 -0
- package/docs/user-manual/09-command-reference/09-campaigns.md +1250 -0
- package/docs/user-manual/09-command-reference/10-emails.md +807 -0
- package/docs/user-manual/09-command-reference/11-reports.md +1135 -0
- package/docs/user-manual/09-command-reference/12-webhooks.md +773 -0
- package/docs/user-manual/09-command-reference/13-suppressed.md +797 -0
- package/docs/user-manual/09-command-reference/14-interests.md +630 -0
- package/docs/user-manual/09-command-reference/15-tags.md +584 -0
- package/docs/user-manual/09-command-reference/16-logs.md +656 -0
- package/docs/user-manual/09-command-reference/17-transactional-templates.md +850 -0
- package/docs/user-manual/10-troubleshooting/01-common-errors.md +457 -0
- package/docs/user-manual/10-troubleshooting/02-authentication-issues.md +558 -0
- package/docs/user-manual/10-troubleshooting/03-connection-problems.md +634 -0
- package/docs/user-manual/10-troubleshooting/04-debugging.md +725 -0
- package/docs/user-manual/11-appendix/04-faq.md +484 -0
- package/docs/user-manual/11-appendix/05-glossary.md +250 -0
- package/docs/user-manual/README.md +0 -0
- package/package.json +13 -47
- package/src/cli.ts +125 -0
- package/src/client.ts +16 -0
- package/src/commands/account.ts +267 -0
- package/src/commands/accounts.ts +78 -0
- package/src/commands/actions.ts +249 -0
- package/src/commands/attributes.ts +139 -0
- package/src/commands/campaign-blueprints.ts +106 -0
- package/src/commands/campaigns.ts +469 -0
- package/src/commands/config.ts +77 -0
- package/src/commands/contacts.ts +612 -0
- package/src/commands/custom-attributes.ts +127 -0
- package/src/commands/dkims.ts +117 -0
- package/src/commands/domains.ts +82 -0
- package/src/commands/email-apis.ts +569 -0
- package/src/commands/emails.ts +197 -0
- package/src/commands/forms.ts +283 -0
- package/src/commands/interests.ts +155 -0
- package/src/commands/links.ts +38 -0
- package/src/commands/lists.ts +406 -0
- package/src/commands/logos.ts +71 -0
- package/src/commands/logs.ts +386 -0
- package/src/commands/reports.ts +306 -0
- package/src/commands/segments.ts +158 -0
- package/src/commands/senders.ts +204 -0
- package/src/commands/sub-accounts.ts +271 -0
- package/src/commands/suppressed-emails.ts +234 -0
- package/src/commands/suppressed.ts +198 -0
- package/src/commands/system-emails.ts +85 -0
- package/src/commands/tags.ts +146 -0
- package/src/commands/tasks.ts +116 -0
- package/src/commands/templates.ts +189 -0
- package/src/commands/tokens.ts +83 -0
- package/src/commands/transactional-emails.ts +374 -0
- package/src/commands/transactional-templates.ts +385 -0
- package/src/commands/users.ts +506 -0
- package/src/commands/webhooks.ts +172 -0
- package/src/commands/workflow-blueprints.ts +123 -0
- package/src/commands/workflows.ts +265 -0
- package/src/types/profile.ts +93 -0
- package/src/utils/auth.ts +272 -0
- package/src/utils/config-file.ts +96 -0
- package/src/utils/config.ts +134 -0
- package/src/utils/confirm.ts +32 -0
- package/src/utils/defaults.ts +99 -0
- package/src/utils/errors.ts +116 -0
- package/src/utils/interactive.ts +91 -0
- package/src/utils/list-defaults.ts +74 -0
- package/src/utils/output.ts +190 -0
- package/src/utils/progress.ts +320 -0
- package/src/utils/spinner.ts +22 -0
- package/tests/IMPLEMENTATION_STATUS.md +258 -0
- package/tests/PTY_SETUP.md +118 -0
- package/tests/PTY_TESTING_GUIDE.md +507 -0
- package/tests/README.md +244 -0
- package/tests/fixtures/api-responses/campaigns.json +34 -0
- package/tests/fixtures/test-config.json +13 -0
- package/tests/helpers/cli-runner.ts +128 -0
- package/tests/helpers/mock-server.ts +301 -0
- package/tests/helpers/pty-runner.ts +181 -0
- package/tests/integration/campaigns-real-api.test.ts +196 -0
- package/tests/integration/setup-integration.ts +50 -0
- package/tests/pty/campaigns.test.ts +241 -0
- package/tests/setup.ts +34 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +28 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import { writeFileSync, readFileSync, existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { CakemailClient as SDK } from '@cakemail-org/cakemail-sdk';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { ProfileType, PROFILE_DESCRIPTIONS } from '../types/profile.js';
|
|
7
|
+
import { updateConfigFile, configFileExists } from './config-file.js';
|
|
8
|
+
|
|
9
|
+
export interface Credentials {
|
|
10
|
+
email: string;
|
|
11
|
+
password: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Prompts the user to select their profile type
|
|
16
|
+
*/
|
|
17
|
+
export async function promptForProfile(): Promise<ProfileType> {
|
|
18
|
+
console.log(chalk.cyan('\n✨ One more thing - let\'s personalize your experience!\n'));
|
|
19
|
+
console.log(chalk.white('What best describes how you\'ll use Cakemail CLI?\n'));
|
|
20
|
+
|
|
21
|
+
// Build choices from profile descriptions
|
|
22
|
+
const choices = Object.entries(PROFILE_DESCRIPTIONS).map(([key, info], index) => {
|
|
23
|
+
const description = info.description.map(d => ` ${chalk.gray('•')} ${d}`).join('\n');
|
|
24
|
+
return {
|
|
25
|
+
name: `${index + 1}. ${info.name}\n${description}\n`,
|
|
26
|
+
value: key as ProfileType,
|
|
27
|
+
short: info.name
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const answer = await inquirer.prompt([
|
|
32
|
+
{
|
|
33
|
+
type: 'list',
|
|
34
|
+
name: 'profile',
|
|
35
|
+
message: 'Choose your profile:',
|
|
36
|
+
choices,
|
|
37
|
+
pageSize: 10
|
|
38
|
+
}
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
console.log(chalk.gray(`\n💡 You can change this anytime with: ${chalk.cyan('cakemail config profile <type>')}\n`));
|
|
42
|
+
|
|
43
|
+
return answer.profile;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Prompts the user for their Cakemail credentials
|
|
48
|
+
*/
|
|
49
|
+
export async function promptForCredentials(): Promise<Credentials> {
|
|
50
|
+
console.log(chalk.yellow('⚠ Not authenticated'));
|
|
51
|
+
console.log(chalk.gray('Please enter your Cakemail credentials:\n'));
|
|
52
|
+
|
|
53
|
+
const answers = await inquirer.prompt([
|
|
54
|
+
{
|
|
55
|
+
type: 'input',
|
|
56
|
+
name: 'email',
|
|
57
|
+
message: 'Email:',
|
|
58
|
+
validate: (input: string) => {
|
|
59
|
+
if (!input || !input.includes('@')) {
|
|
60
|
+
return 'Please enter a valid email address';
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
type: 'password',
|
|
67
|
+
name: 'password',
|
|
68
|
+
message: 'Password:',
|
|
69
|
+
mask: '*',
|
|
70
|
+
validate: (input: string) => {
|
|
71
|
+
if (!input || input.length < 1) {
|
|
72
|
+
return 'Please enter your password';
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
email: answers.email,
|
|
81
|
+
password: answers.password
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Tests credentials by attempting to authenticate with the API
|
|
87
|
+
* Returns token information if successful
|
|
88
|
+
*/
|
|
89
|
+
export async function testCredentials(email: string, password: string): Promise<{
|
|
90
|
+
valid: boolean;
|
|
91
|
+
accessToken?: string;
|
|
92
|
+
refreshToken?: string;
|
|
93
|
+
expiresIn?: number;
|
|
94
|
+
accounts?: number[];
|
|
95
|
+
}> {
|
|
96
|
+
try {
|
|
97
|
+
const sdk = new SDK({
|
|
98
|
+
email,
|
|
99
|
+
password,
|
|
100
|
+
baseURL: process.env.CAKEMAIL_API_BASE || 'https://api.cakemail.dev'
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Get tokens using the TokenService
|
|
104
|
+
const tokenResponse = await sdk.tokenService.createToken({
|
|
105
|
+
formData: {
|
|
106
|
+
grant_type: 'password',
|
|
107
|
+
username: email,
|
|
108
|
+
password: password
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Handle potential MFA challenge
|
|
113
|
+
if ('challenge' in tokenResponse) {
|
|
114
|
+
// MFA is enabled - for now we'll return false
|
|
115
|
+
// TODO: Implement MFA flow
|
|
116
|
+
console.log(chalk.yellow('⚠ Multi-factor authentication is enabled on this account'));
|
|
117
|
+
console.log(chalk.gray('MFA support is coming soon. Please disable MFA temporarily.'));
|
|
118
|
+
return { valid: false };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
valid: true,
|
|
123
|
+
accessToken: tokenResponse.access_token,
|
|
124
|
+
refreshToken: tokenResponse.refresh_token,
|
|
125
|
+
expiresIn: tokenResponse.expires_in,
|
|
126
|
+
accounts: tokenResponse.accounts
|
|
127
|
+
};
|
|
128
|
+
} catch (error: any) {
|
|
129
|
+
return { valid: false };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Saves credentials to .env file in current working directory
|
|
135
|
+
*/
|
|
136
|
+
export function saveCredentials(email: string, password: string, accountId?: string): void {
|
|
137
|
+
const envPath = join(process.cwd(), '.env');
|
|
138
|
+
let envContent = '';
|
|
139
|
+
|
|
140
|
+
// Read existing .env if it exists
|
|
141
|
+
if (existsSync(envPath)) {
|
|
142
|
+
envContent = readFileSync(envPath, 'utf-8');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Parse existing env variables
|
|
146
|
+
const envLines = envContent.split('\n');
|
|
147
|
+
const envVars: Record<string, string> = {};
|
|
148
|
+
|
|
149
|
+
envLines.forEach(line => {
|
|
150
|
+
const match = line.match(/^([^=]+)=(.*)$/);
|
|
151
|
+
if (match) {
|
|
152
|
+
envVars[match[1].trim()] = match[2].trim();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Update credentials
|
|
157
|
+
envVars['CAKEMAIL_EMAIL'] = email;
|
|
158
|
+
envVars['CAKEMAIL_PASSWORD'] = password;
|
|
159
|
+
|
|
160
|
+
if (accountId !== undefined) {
|
|
161
|
+
envVars['CAKEMAIL_CURRENT_ACCOUNT_ID'] = accountId;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Rebuild .env content
|
|
165
|
+
const newEnvContent = Object.entries(envVars)
|
|
166
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
167
|
+
.join('\n') + '\n';
|
|
168
|
+
|
|
169
|
+
writeFileSync(envPath, newEnvContent, 'utf-8');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Interactive authentication flow - prompts for credentials and saves them
|
|
174
|
+
*/
|
|
175
|
+
export async function authenticateInteractively(): Promise<Credentials> {
|
|
176
|
+
const credentials = await promptForCredentials();
|
|
177
|
+
|
|
178
|
+
console.log(chalk.gray('\nValidating credentials...'));
|
|
179
|
+
|
|
180
|
+
const authResult = await testCredentials(credentials.email, credentials.password);
|
|
181
|
+
|
|
182
|
+
if (!authResult.valid) {
|
|
183
|
+
console.log(chalk.red('✗ Invalid credentials. Please try again.\n'));
|
|
184
|
+
// Retry
|
|
185
|
+
return authenticateInteractively();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
console.log(chalk.green('✓ Authenticated'));
|
|
189
|
+
|
|
190
|
+
// Fetch account info to show welcome message
|
|
191
|
+
let accountId: string | undefined;
|
|
192
|
+
try {
|
|
193
|
+
const sdk = new SDK({
|
|
194
|
+
email: credentials.email,
|
|
195
|
+
password: credentials.password,
|
|
196
|
+
baseURL: process.env.CAKEMAIL_API_BASE || 'https://api.cakemail.dev'
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const accountResponse = await sdk.accountService.getSelfAccount();
|
|
200
|
+
const account = accountResponse.data;
|
|
201
|
+
accountId = account.id;
|
|
202
|
+
|
|
203
|
+
// List all accessible accounts with count
|
|
204
|
+
const accountsResponse = await sdk.subAccountService.listAccounts({
|
|
205
|
+
partnerAccountId: parseInt(account.id),
|
|
206
|
+
recursive: true,
|
|
207
|
+
withCount: true
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Use actual count from pagination if available, otherwise show "X+" for first page
|
|
211
|
+
const subAccountCount = accountsResponse.pagination?.count ?? accountsResponse.data?.length ?? 0;
|
|
212
|
+
const totalAccounts = 1 + subAccountCount;
|
|
213
|
+
const hasMorePages = accountsResponse.pagination?.count === null && (accountsResponse.data?.length || 0) >= 50;
|
|
214
|
+
|
|
215
|
+
const accountMessage = hasMorePages
|
|
216
|
+
? `Welcome! You have access to ${totalAccounts}+ accounts.`
|
|
217
|
+
: `Welcome! You have access to ${totalAccounts} account${totalAccounts > 1 ? 's' : ''}.`;
|
|
218
|
+
|
|
219
|
+
console.log(chalk.cyan(`\n${accountMessage}`));
|
|
220
|
+
|
|
221
|
+
if (totalAccounts > 1) {
|
|
222
|
+
console.log(chalk.gray(`Use 'cakemail account list' to see all accounts.`));
|
|
223
|
+
console.log(chalk.gray(`Use 'cakemail account use <id>' to switch accounts.`));
|
|
224
|
+
}
|
|
225
|
+
} catch (error) {
|
|
226
|
+
// If we can't fetch account info, just show basic welcome
|
|
227
|
+
console.log(chalk.cyan('\nWelcome!\n'));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Check if this is first-time setup (no config file exists yet)
|
|
231
|
+
const isFirstTimeSetup = !configFileExists();
|
|
232
|
+
|
|
233
|
+
// Prompt for profile selection on first-time setup
|
|
234
|
+
let selectedProfile: ProfileType | undefined;
|
|
235
|
+
if (isFirstTimeSetup) {
|
|
236
|
+
selectedProfile = await promptForProfile();
|
|
237
|
+
console.log(chalk.green(`✓ Profile set to: ${PROFILE_DESCRIPTIONS[selectedProfile].name}`));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Save to config file (preferred) or fallback to .env
|
|
241
|
+
if (isFirstTimeSetup || configFileExists()) {
|
|
242
|
+
// Save to config file with tokens
|
|
243
|
+
const configUpdate: any = {
|
|
244
|
+
version: '1.0',
|
|
245
|
+
auth: {
|
|
246
|
+
method: 'token',
|
|
247
|
+
access_token: authResult.accessToken,
|
|
248
|
+
refresh_token: authResult.refreshToken,
|
|
249
|
+
expires_in: authResult.expiresIn,
|
|
250
|
+
email: credentials.email,
|
|
251
|
+
base_url: process.env.CAKEMAIL_API_BASE || 'https://api.cakemail.dev'
|
|
252
|
+
},
|
|
253
|
+
defaults: accountId ? { account_id: accountId } : undefined
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// Only set profile during first-time setup to avoid overwriting user's profile choice
|
|
257
|
+
if (isFirstTimeSetup) {
|
|
258
|
+
configUpdate.profile = selectedProfile || 'balanced';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
updateConfigFile(configUpdate);
|
|
262
|
+
console.log(chalk.green('✓ Configuration saved to ~/.cakemail/config.json'));
|
|
263
|
+
} else {
|
|
264
|
+
// Fallback to .env for backward compatibility
|
|
265
|
+
saveCredentials(credentials.email, credentials.password, accountId);
|
|
266
|
+
console.log(chalk.green('✓ Credentials saved to .env'));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
console.log(chalk.gray('\n' + '─'.repeat(50) + '\n'));
|
|
270
|
+
|
|
271
|
+
return credentials;
|
|
272
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// ABOUTME: Persists CLI configuration to ~/.{binaryName}/config.json.
|
|
2
|
+
// ABOUTME: Stores profile choice, auth credentials, and default values.
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { getProfileConfig, type ProfileType, type ProfileConfig } from '../types/profile.js';
|
|
8
|
+
|
|
9
|
+
const CONFIG_DIR = join(homedir(), '.cakemail');
|
|
10
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
11
|
+
|
|
12
|
+
export interface ConfigFile {
|
|
13
|
+
version: string;
|
|
14
|
+
profile?: ProfileType;
|
|
15
|
+
auth?: {
|
|
16
|
+
method?: 'password' | 'token';
|
|
17
|
+
email?: string;
|
|
18
|
+
access_token?: string;
|
|
19
|
+
refresh_token?: string;
|
|
20
|
+
expires_in?: number;
|
|
21
|
+
base_url?: string;
|
|
22
|
+
};
|
|
23
|
+
profiles?: Partial<Record<ProfileType, Partial<ProfileConfig>>>;
|
|
24
|
+
defaults?: {
|
|
25
|
+
list_id?: number;
|
|
26
|
+
sender_id?: number;
|
|
27
|
+
account_id?: number;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ensureConfigDir(): void {
|
|
32
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
33
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function configFileExists(): boolean {
|
|
38
|
+
return existsSync(CONFIG_FILE);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function loadConfigFile(): ConfigFile | undefined {
|
|
42
|
+
if (!configFileExists()) return undefined;
|
|
43
|
+
try {
|
|
44
|
+
const content = readFileSync(CONFIG_FILE, 'utf-8');
|
|
45
|
+
return JSON.parse(content) as ConfigFile;
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function saveConfigFile(config: ConfigFile): void {
|
|
52
|
+
ensureConfigDir();
|
|
53
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function updateConfigFile(updates: Partial<ConfigFile>): void {
|
|
57
|
+
const current = loadConfigFile() || { version: '1.0.0' };
|
|
58
|
+
const merged = { ...current, ...updates };
|
|
59
|
+
saveConfigFile(merged);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getCurrentProfile(): ProfileType {
|
|
63
|
+
const config = loadConfigFile();
|
|
64
|
+
return config?.profile ?? 'balanced';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function setCurrentProfile(profile: ProfileType): void {
|
|
68
|
+
updateConfigFile({ profile });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getResolvedProfileConfig(profileType?: ProfileType): ProfileConfig {
|
|
72
|
+
const type = profileType ?? getCurrentProfile();
|
|
73
|
+
const base = getProfileConfig(type);
|
|
74
|
+
const config = loadConfigFile();
|
|
75
|
+
const overrides = config?.profiles?.[type];
|
|
76
|
+
|
|
77
|
+
if (!overrides) return base;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
output: { ...base.output, ...overrides.output },
|
|
81
|
+
behavior: { ...base.behavior, ...overrides.behavior },
|
|
82
|
+
display: { ...base.display, ...overrides.display },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getDefaults(): { list_id?: number; sender_id?: number; account_id?: number } {
|
|
87
|
+
const config = loadConfigFile();
|
|
88
|
+
return config?.defaults ?? {};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function setDefault(key: 'list_id' | 'sender_id' | 'account_id', value: number): void {
|
|
92
|
+
const config = loadConfigFile() || { version: '1.0.0' };
|
|
93
|
+
if (!config.defaults) config.defaults = {};
|
|
94
|
+
config.defaults[key] = value;
|
|
95
|
+
saveConfigFile(config);
|
|
96
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { config } from 'dotenv';
|
|
2
|
+
import { CakemailConfig } from '../client.js';
|
|
3
|
+
import { authenticateInteractively } from './auth.js';
|
|
4
|
+
import { ProfileType, ProfileConfig, OutputFormat } from '../types/profile.js';
|
|
5
|
+
import {
|
|
6
|
+
loadConfigFile,
|
|
7
|
+
getProfileConfig,
|
|
8
|
+
getCurrentProfile,
|
|
9
|
+
migrateFromEnv,
|
|
10
|
+
ConfigFile
|
|
11
|
+
} from './config-file.js';
|
|
12
|
+
|
|
13
|
+
// Load .env from current working directory (fallback for backward compatibility)
|
|
14
|
+
config({ path: process.cwd() + '/.env' });
|
|
15
|
+
|
|
16
|
+
// Re-export types for backward compatibility
|
|
17
|
+
export type { OutputFormat, ProfileType, ProfileConfig };
|
|
18
|
+
|
|
19
|
+
export interface FullConfig extends CakemailConfig {
|
|
20
|
+
outputFormat?: OutputFormat;
|
|
21
|
+
currentAccountId?: string;
|
|
22
|
+
profile?: ProfileType;
|
|
23
|
+
profileConfig?: ProfileConfig;
|
|
24
|
+
refreshToken?: string;
|
|
25
|
+
expiresIn?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function getConfig(required: boolean = true, interactive: boolean = true): Promise<FullConfig> {
|
|
29
|
+
// Try to load from config file first
|
|
30
|
+
let configFile = loadConfigFile();
|
|
31
|
+
|
|
32
|
+
// If no config file exists, try to migrate from .env
|
|
33
|
+
if (!configFile) {
|
|
34
|
+
configFile = migrateFromEnv();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Load credentials from config file or .env
|
|
38
|
+
let accessToken = configFile?.auth?.access_token || configFile?.auth?.token || process.env.CAKEMAIL_ACCESS_TOKEN;
|
|
39
|
+
let refreshToken = configFile?.auth?.refresh_token;
|
|
40
|
+
let expiresIn = configFile?.auth?.expires_in;
|
|
41
|
+
let email = configFile?.auth?.email || process.env.CAKEMAIL_EMAIL || process.env.CAKEMAIL_USERNAME;
|
|
42
|
+
let password = process.env.CAKEMAIL_PASSWORD; // Password not stored in config (security)
|
|
43
|
+
const baseURL = process.env.CAKEMAIL_API_BASE || configFile?.auth?.base_url;
|
|
44
|
+
const currentAccountId = configFile?.defaults?.account_id || process.env.CAKEMAIL_CURRENT_ACCOUNT_ID || undefined;
|
|
45
|
+
|
|
46
|
+
// Load profile configuration
|
|
47
|
+
const profile = getCurrentProfile();
|
|
48
|
+
const profileConfig = getProfileConfig(profile);
|
|
49
|
+
|
|
50
|
+
// Output format priority: env var > profile config > default
|
|
51
|
+
const outputFormat = (process.env.CAKEMAIL_OUTPUT_FORMAT as OutputFormat) || profileConfig.output.format;
|
|
52
|
+
|
|
53
|
+
// If credentials are missing and interactive mode is enabled, prompt the user
|
|
54
|
+
if (required && !accessToken && (!email || !password)) {
|
|
55
|
+
if (interactive) {
|
|
56
|
+
// Interactive authentication flow
|
|
57
|
+
const credentials = await authenticateInteractively();
|
|
58
|
+
email = credentials.email;
|
|
59
|
+
password = credentials.password;
|
|
60
|
+
|
|
61
|
+
// Reload config file (it was updated by auth flow)
|
|
62
|
+
configFile = loadConfigFile();
|
|
63
|
+
|
|
64
|
+
// Reload .env to get any updates from authentication (backward compatibility)
|
|
65
|
+
config({ path: process.cwd() + '/.env', override: true });
|
|
66
|
+
} else {
|
|
67
|
+
throw new Error(
|
|
68
|
+
'Missing credentials. Set CAKEMAIL_ACCESS_TOKEN or (CAKEMAIL_EMAIL and CAKEMAIL_PASSWORD) in environment variables, .env file, or run authentication'
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
accessToken,
|
|
75
|
+
refreshToken,
|
|
76
|
+
expiresIn,
|
|
77
|
+
email,
|
|
78
|
+
password,
|
|
79
|
+
baseURL,
|
|
80
|
+
outputFormat,
|
|
81
|
+
currentAccountId,
|
|
82
|
+
profile,
|
|
83
|
+
profileConfig,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Synchronous version of getConfig for backwards compatibility
|
|
89
|
+
* Use this only when you're certain credentials are already set
|
|
90
|
+
*/
|
|
91
|
+
export function getConfigSync(required: boolean = true): FullConfig {
|
|
92
|
+
// Try to load from config file first
|
|
93
|
+
let configFile = loadConfigFile();
|
|
94
|
+
|
|
95
|
+
// If no config file exists, try to migrate from .env
|
|
96
|
+
if (!configFile) {
|
|
97
|
+
configFile = migrateFromEnv();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Load credentials from config file or .env
|
|
101
|
+
const accessToken = configFile?.auth?.access_token || configFile?.auth?.token || process.env.CAKEMAIL_ACCESS_TOKEN;
|
|
102
|
+
const refreshToken = configFile?.auth?.refresh_token;
|
|
103
|
+
const expiresIn = configFile?.auth?.expires_in;
|
|
104
|
+
const email = configFile?.auth?.email || process.env.CAKEMAIL_EMAIL || process.env.CAKEMAIL_USERNAME;
|
|
105
|
+
const password = process.env.CAKEMAIL_PASSWORD; // Password not stored in config (security)
|
|
106
|
+
const baseURL = process.env.CAKEMAIL_API_BASE || configFile?.auth?.base_url;
|
|
107
|
+
const currentAccountId = configFile?.defaults?.account_id || process.env.CAKEMAIL_CURRENT_ACCOUNT_ID || undefined;
|
|
108
|
+
|
|
109
|
+
// Load profile configuration
|
|
110
|
+
const profile = getCurrentProfile();
|
|
111
|
+
const profileConfig = getProfileConfig(profile);
|
|
112
|
+
|
|
113
|
+
// Output format priority: env var > profile config > default
|
|
114
|
+
const outputFormat = (process.env.CAKEMAIL_OUTPUT_FORMAT as OutputFormat) || profileConfig.output.format;
|
|
115
|
+
|
|
116
|
+
if (required && !accessToken && (!email || !password)) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
'Missing credentials. Set CAKEMAIL_ACCESS_TOKEN or (CAKEMAIL_EMAIL and CAKEMAIL_PASSWORD) in environment variables, .env file, or run authentication'
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
accessToken,
|
|
124
|
+
refreshToken,
|
|
125
|
+
expiresIn,
|
|
126
|
+
email,
|
|
127
|
+
password,
|
|
128
|
+
baseURL,
|
|
129
|
+
outputFormat,
|
|
130
|
+
currentAccountId,
|
|
131
|
+
profile,
|
|
132
|
+
profileConfig,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// ABOUTME: Profile-aware confirmation prompt for destructive operations.
|
|
2
|
+
// ABOUTME: Skips in developer profile or batch mode.
|
|
3
|
+
|
|
4
|
+
import * as readline from 'node:readline';
|
|
5
|
+
import type { ProfileConfig } from '../types/profile.js';
|
|
6
|
+
|
|
7
|
+
export async function confirmDelete(
|
|
8
|
+
resourceType: string,
|
|
9
|
+
resourceId: string,
|
|
10
|
+
profileConfig?: ProfileConfig,
|
|
11
|
+
): Promise<boolean> {
|
|
12
|
+
const shouldConfirm = profileConfig?.behavior.confirm_destructive ?? true;
|
|
13
|
+
|
|
14
|
+
if (!shouldConfirm || !process.stdout.isTTY || process.argv.includes('--batch')) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const rl = readline.createInterface({
|
|
19
|
+
input: process.stdin,
|
|
20
|
+
output: process.stdout,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
rl.question(
|
|
25
|
+
`Delete ${resourceType} "${resourceId}"? This cannot be undone. [y/N] `,
|
|
26
|
+
(answer) => {
|
|
27
|
+
rl.close();
|
|
28
|
+
resolve(answer.toLowerCase() === 'y');
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// ABOUTME: Smart auto-detection for common resources.
|
|
2
|
+
// ABOUTME: Uses session cache and config defaults to reduce required flags.
|
|
3
|
+
|
|
4
|
+
import { getDefaults, setDefault } from './config-file.js';
|
|
5
|
+
import type { OutputFormatter } from './output.js';
|
|
6
|
+
|
|
7
|
+
interface CachedResource {
|
|
8
|
+
id: number | string;
|
|
9
|
+
name?: string;
|
|
10
|
+
timestamp: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
14
|
+
const sessionCache = new Map<string, CachedResource>();
|
|
15
|
+
|
|
16
|
+
export async function autoDetectList(
|
|
17
|
+
client: any,
|
|
18
|
+
formatter: OutputFormatter,
|
|
19
|
+
providedListId?: number,
|
|
20
|
+
): Promise<number | undefined> {
|
|
21
|
+
if (providedListId) {
|
|
22
|
+
sessionCache.set('list', { id: providedListId, timestamp: Date.now() });
|
|
23
|
+
return providedListId;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Check session cache
|
|
27
|
+
const cached = sessionCache.get('list');
|
|
28
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
|
29
|
+
return cached.id as number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Check config defaults
|
|
33
|
+
const defaults = getDefaults();
|
|
34
|
+
if (defaults.list_id) return defaults.list_id;
|
|
35
|
+
|
|
36
|
+
// Fetch all lists and auto-select if only one
|
|
37
|
+
try {
|
|
38
|
+
const result = await client.listService?.listLists?.({ page: 1, perPage: 2 });
|
|
39
|
+
const lists = result?.data ?? [];
|
|
40
|
+
|
|
41
|
+
if (lists.length === 1) {
|
|
42
|
+
const listId = lists[0].id;
|
|
43
|
+
sessionCache.set('list', { id: listId, name: lists[0].name, timestamp: Date.now() });
|
|
44
|
+
formatter.info(`Using list: ${lists[0].name} (${listId})`);
|
|
45
|
+
return listId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (lists.length > 1) {
|
|
49
|
+
formatter.info('Multiple lists found. Use --list-id to specify.');
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Auto-detection is best-effort
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function autoDetectSender(
|
|
59
|
+
client: any,
|
|
60
|
+
formatter: OutputFormatter,
|
|
61
|
+
providedSenderId?: number,
|
|
62
|
+
): Promise<number | undefined> {
|
|
63
|
+
if (providedSenderId) {
|
|
64
|
+
sessionCache.set('sender', { id: providedSenderId, timestamp: Date.now() });
|
|
65
|
+
return providedSenderId;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const cached = sessionCache.get('sender');
|
|
69
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
|
70
|
+
return cached.id as number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const defaults = getDefaults();
|
|
74
|
+
if (defaults.sender_id) return defaults.sender_id;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const result = await client.senderService?.listSenders?.({ page: 1, perPage: 2 });
|
|
78
|
+
const senders = (result?.data ?? []).filter((s: any) => s.confirmed);
|
|
79
|
+
|
|
80
|
+
if (senders.length === 1) {
|
|
81
|
+
const senderId = senders[0].id;
|
|
82
|
+
sessionCache.set('sender', { id: senderId, name: senders[0].email, timestamp: Date.now() });
|
|
83
|
+
formatter.info(`Using sender: ${senders[0].email} (${senderId})`);
|
|
84
|
+
return senderId;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (senders.length > 1) {
|
|
88
|
+
formatter.info('Multiple senders found. Use --sender-id to specify.');
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// Auto-detection is best-effort
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function clearCache(): void {
|
|
98
|
+
sessionCache.clear();
|
|
99
|
+
}
|