@mailmodo/cli 0.0.26-beta.pr29.44 → 0.0.27-beta.pr30.46

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.
@@ -3,24 +3,23 @@ export default class Billing extends BaseCommand {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
+ 'auto-charge-block-count': import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
6
7
  cap: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ checkout: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ purchase: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
10
  status: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
11
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
12
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
13
  };
11
14
  run(): Promise<void>;
12
- /**
13
- * Retrieves and displays the current billing status including card info,
14
- * block usage, spending total, AI generation limits, and optionally
15
- * opens Stripe Checkout if no card is on file.
16
- */
15
+ private startCheckout;
17
16
  private showStatus;
18
- /**
19
- * Updates the monthly block spending cap. This limits how many 10k-email
20
- * blocks will be auto-charged per billing cycle.
21
- *
22
- * @param {number} cap - The maximum number of blocks per month.
23
- * @param {boolean} jsonOutput - Whether to output JSON instead of formatted text.
24
- */
17
+ private formatAutoCharge;
18
+ private formatCap;
19
+ private formatCurrency;
20
+ private formatPaymentMethod;
21
+ private formatUsageBlock;
22
+ private pluralize;
23
+ private purchaseBlocks;
25
24
  private setCap;
26
25
  }
@@ -4,16 +4,29 @@ import open from 'open';
4
4
  import { BaseCommand } from '../../lib/base-command.js';
5
5
  import { API_ENDPOINTS } from '../../lib/constants.js';
6
6
  export default class Billing extends BaseCommand {
7
- static description = 'View billing status, manage payment, and set spending cap';
7
+ static description = 'View billing status, purchase blocks, set cap, or add a payment method';
8
8
  static examples = [
9
9
  '<%= config.bin %> billing',
10
10
  '<%= config.bin %> billing --status',
11
11
  '<%= config.bin %> billing --cap 5',
12
+ '<%= config.bin %> billing --cap 5 --auto-charge-block-count 2',
13
+ '<%= config.bin %> billing --purchase 3',
14
+ '<%= config.bin %> billing --checkout',
12
15
  ];
13
16
  static flags = {
14
17
  ...BaseCommand.baseFlags,
18
+ 'auto-charge-block-count': Flags.integer({
19
+ description: 'Blocks to auto-purchase when the quota runs low (use with --cap)',
20
+ }),
15
21
  cap: Flags.integer({
16
- description: 'Set monthly block cap (max blocks to auto-charge)',
22
+ description: 'Set monthly sending cap in blocks',
23
+ }),
24
+ checkout: Flags.boolean({
25
+ default: false,
26
+ description: 'Open Stripe checkout to add or update a payment method',
27
+ }),
28
+ purchase: Flags.integer({
29
+ description: 'Manually purchase this many 10,000-email blocks',
17
30
  }),
18
31
  status: Flags.boolean({
19
32
  default: false,
@@ -23,17 +36,44 @@ export default class Billing extends BaseCommand {
23
36
  async run() {
24
37
  const { flags } = await this.parse(Billing);
25
38
  await this.ensureAuth();
39
+ const autoChargeBlockCount = flags['auto-charge-block-count'];
40
+ if (autoChargeBlockCount !== undefined && flags.cap === undefined) {
41
+ this.error(`Use ${chalk.cyan('--auto-charge-block-count')} together with ${chalk.cyan('--cap')}.`);
42
+ }
43
+ if (flags.checkout) {
44
+ await this.startCheckout(flags.json);
45
+ return;
46
+ }
47
+ if (flags.purchase !== undefined) {
48
+ await this.purchaseBlocks(flags.purchase, flags.json);
49
+ return;
50
+ }
26
51
  if (flags.cap !== undefined) {
27
- await this.setCap(flags.cap, flags.json);
52
+ await this.setCap(flags.cap, autoChargeBlockCount, flags.json);
28
53
  return;
29
54
  }
30
55
  await this.showStatus(flags.json);
31
56
  }
32
- /**
33
- * Retrieves and displays the current billing status including card info,
34
- * block usage, spending total, AI generation limits, and optionally
35
- * opens Stripe Checkout if no card is on file.
36
- */
57
+ async startCheckout(jsonOutput) {
58
+ const response = await this.withApiSpinner({ json: jsonOutput, text: ' Creating checkout session...' }, () => this.apiClient.post(API_ENDPOINTS.BILLING_CHECKOUT));
59
+ if (!response.ok) {
60
+ this.handleApiError(response);
61
+ }
62
+ const { checkoutUrl } = response.data;
63
+ if (jsonOutput) {
64
+ this.log(JSON.stringify({ checkoutUrl }, null, 2));
65
+ return;
66
+ }
67
+ this.log(`\n ${chalk.bold('Stripe Checkout')} — add or update your payment method.`);
68
+ this.log(` ${chalk.dim(checkoutUrl)}\n`);
69
+ try {
70
+ await open(checkoutUrl);
71
+ this.log(` Opening in browser...\n`);
72
+ }
73
+ catch {
74
+ this.log(` ${chalk.dim('Could not open browser. Visit the URL above manually.')}\n`);
75
+ }
76
+ }
37
77
  async showStatus(jsonOutput) {
38
78
  const response = await this.withApiSpinner({ json: jsonOutput, text: ' Loading billing status...' }, () => this.apiClient.get(API_ENDPOINTS.BILLING_STATUS));
39
79
  if (!response.ok) {
@@ -45,55 +85,125 @@ export default class Billing extends BaseCommand {
45
85
  return;
46
86
  }
47
87
  this.log('');
48
- if (data.cardOnFile) {
49
- this.log(` Card: ${data.cardSummary || 'Card on file'}`);
88
+ this.log(` Tier: ${data.tier}`);
89
+ this.log(` Payment: ${this.formatPaymentMethod(data)}`);
90
+ this.log(` Auto-charge: ${this.formatAutoCharge(data)}`);
91
+ this.log(` Monthly cap: ${this.formatCap(data.cap)}`);
92
+ this.log(` Total spent: ${this.formatCurrency(data.totalSpent, data.spentCurrency)}`);
93
+ if (data.activeBlocks.length === 0) {
94
+ this.log(' Active blocks: none');
95
+ this.log('');
96
+ return;
50
97
  }
51
- else {
52
- this.log(` Card: ${chalk.yellow('No card on file')}`);
98
+ this.log(' Active blocks:');
99
+ for (const block of data.activeBlocks) {
100
+ this.log(` - ${this.formatUsageBlock(block)}`);
53
101
  }
54
- if (data.freeExhausted) {
55
- this.log(` Free tier: ${chalk.dim('exhausted')}`);
102
+ this.log('');
103
+ }
104
+ formatAutoCharge(data) {
105
+ if (!data.autoChargeEnabled) {
106
+ return 'disabled';
56
107
  }
57
- else {
58
- this.log(` Free tier: active`);
108
+ if (typeof data.autoChargeBlockCount !== 'number') {
109
+ return 'enabled';
59
110
  }
60
- this.log(` Current block: ${data.currentBlockUsed ?? 0} / 10,000 used`);
61
- this.log(` Blocks used: ${data.blocksUsed ?? 0} ($${data.totalSpent || '0'} total)`);
62
- this.log(` Monthly cap: ${data.cap ?? 'not set'} blocks${data.cap ? ` ($${data.cap * 19} max/month)` : ''}`);
63
- if (data.aiRemaining) {
64
- this.log(`\n AI generation:`);
65
- this.log(` Init uses remaining: ${data.aiRemaining.inits}/5 this month`);
66
- this.log(` Edit uses remaining: ${data.aiRemaining.edits}/50 this month`);
111
+ return `enabled (${data.autoChargeBlockCount} ${this.pluralize('block', data.autoChargeBlockCount)})`;
112
+ }
113
+ formatCap(cap) {
114
+ const parts = [];
115
+ if (typeof cap.inBlocks === 'number') {
116
+ parts.push(`${cap.inBlocks} ${this.pluralize('block', cap.inBlocks)}`);
67
117
  }
68
- if (!data.cardOnFile && data.freeExhausted && data.checkoutUrl) {
69
- this.log(`\n ${chalk.yellow('Free tier exhausted.')} Opening payment page...\n`);
118
+ if (typeof cap.inEmails === 'number') {
119
+ parts.push(`${cap.inEmails.toLocaleString()} emails`);
120
+ }
121
+ return parts.length > 0 ? parts.join(' / ') : 'not set';
122
+ }
123
+ formatCurrency(amount, currency) {
124
+ const numericAmount = typeof amount === 'number' ? amount : Number.parseFloat(amount);
125
+ const normalizedCurrency = currency.toUpperCase();
126
+ if (Number.isFinite(numericAmount)) {
70
127
  try {
71
- await open(data.checkoutUrl);
128
+ return new Intl.NumberFormat('en-US', {
129
+ currency: normalizedCurrency,
130
+ style: 'currency',
131
+ }).format(numericAmount);
72
132
  }
73
133
  catch {
74
- this.log(` Visit: ${chalk.cyan(data.checkoutUrl)}`);
134
+ return `${numericAmount.toFixed(2)} ${normalizedCurrency}`;
75
135
  }
76
136
  }
77
- this.log('');
137
+ return `${String(amount)} ${normalizedCurrency}`;
138
+ }
139
+ formatPaymentMethod(data) {
140
+ if (!data.hasPaymentMethod) {
141
+ return 'No payment method on file';
142
+ }
143
+ const primaryMethod = data.paymentMethod[0];
144
+ if (!primaryMethod) {
145
+ return 'Payment method on file';
146
+ }
147
+ const brand = primaryMethod.brand || 'Card';
148
+ return primaryMethod.last4
149
+ ? `${brand} ending ${primaryMethod.last4}`
150
+ : `${brand} on file`;
78
151
  }
79
- /**
80
- * Updates the monthly block spending cap. This limits how many 10k-email
81
- * blocks will be auto-charged per billing cycle.
82
- *
83
- * @param {number} cap - The maximum number of blocks per month.
84
- * @param {boolean} jsonOutput - Whether to output JSON instead of formatted text.
85
- */
86
- async setCap(cap, jsonOutput) {
87
- const response = await this.withApiSpinner({ json: jsonOutput, text: ' Updating spending cap...' }, () => this.apiClient.patch(API_ENDPOINTS.BILLING_CAP, {
88
- cap,
152
+ formatUsageBlock(block) {
153
+ const allowance = block.blockAllowance ?? Math.max(block.blocksCount * block.blockSize, 0);
154
+ const used = Math.max(block.emailsSent ?? 0, 0);
155
+ const activationSuffix = block.activatedAt
156
+ ? `, activated ${new Date(block.activatedAt).toLocaleDateString('en-US')}`
157
+ : '';
158
+ return `${block.type} block: ${used.toLocaleString()} / ${allowance.toLocaleString()} used (${block.status}${activationSuffix})`;
159
+ }
160
+ pluralize(word, count) {
161
+ return count === 1 ? word : `${word}s`;
162
+ }
163
+ async purchaseBlocks(blocksCount, jsonOutput) {
164
+ if (blocksCount < 1) {
165
+ this.error('Purchase block count must be at least 1 block.');
166
+ }
167
+ const response = await this.withApiSpinner({ json: jsonOutput, text: ' Initiating block purchase...' }, () => this.apiClient.post(API_ENDPOINTS.BILLING_PURCHASE, {
168
+ blocksCount,
89
169
  }));
90
170
  if (!response.ok) {
91
171
  this.handleApiError(response);
92
172
  }
93
173
  if (jsonOutput) {
94
- this.log(JSON.stringify({ cap, maxMonthlySpend: `$${cap * 19}`, status: 'updated' }, null, 2));
174
+ this.log(JSON.stringify(response.data, null, 2));
95
175
  return;
96
176
  }
97
- this.log(`\n ${chalk.green('✓')} Cap set to ${chalk.bold(String(cap))} blocks ($${cap * 19} max/month).\n`);
177
+ this.log(`\n ${chalk.green('✓')} ${response.data.message}`);
178
+ this.log(` Blocks: ${blocksCount} ${this.pluralize('block', blocksCount)}`);
179
+ this.log(` Payment intent:${` ${chalk.dim(response.data.paymentIntentId)}`}`);
180
+ this.log('');
181
+ }
182
+ async setCap(cap, autoChargeBlockCount, jsonOutput) {
183
+ if (cap < 1) {
184
+ this.error('Cap must be at least 1 block.');
185
+ }
186
+ if (autoChargeBlockCount !== undefined && autoChargeBlockCount < 1) {
187
+ this.error('Auto-charge block count must be at least 1 block.');
188
+ }
189
+ const response = await this.withApiSpinner({ json: jsonOutput, text: ' Updating spending cap...' }, () => {
190
+ const payload = autoChargeBlockCount === undefined
191
+ ? { cap }
192
+ : { autoChargeBlockCount, cap };
193
+ return this.apiClient.post(API_ENDPOINTS.BILLING_CAP, payload);
194
+ });
195
+ if (!response.ok) {
196
+ this.handleApiError(response);
197
+ }
198
+ if (jsonOutput) {
199
+ this.log(JSON.stringify(response.data, null, 2));
200
+ return;
201
+ }
202
+ this.log(`\n ${chalk.green('✓')} ${response.data.message}`);
203
+ this.log(` Monthly cap: ${chalk.bold(String(response.data.capBlocks))} ${this.pluralize('block', response.data.capBlocks)} (${response.data.capEmails.toLocaleString()} emails)`);
204
+ if (response.data.autoChargeBlockCount !== undefined) {
205
+ this.log(` Auto-charge: ${response.data.autoChargeBlockCount} ${this.pluralize('block', response.data.autoChargeBlockCount)}`);
206
+ }
207
+ this.log('');
98
208
  }
99
209
  }
@@ -2,6 +2,7 @@ import { Flags } from '@oclif/core';
2
2
  import { confirm } from '@inquirer/prompts';
3
3
  import chalk from 'chalk';
4
4
  import { writeFile } from 'node:fs/promises';
5
+ import { resolve } from 'node:path';
5
6
  import { BaseCommand } from '../../lib/base-command.js';
6
7
  import { API_ENDPOINTS } from '../../lib/constants.js';
7
8
  export default class Contacts extends BaseCommand {
@@ -9,7 +10,7 @@ export default class Contacts extends BaseCommand {
9
10
  static examples = [
10
11
  '<%= config.bin %> contacts',
11
12
  '<%= config.bin %> contacts --search sarah@example.com',
12
- "<%= config.bin %> contacts --export # GDPR CSV → contacts.csv",
13
+ '<%= config.bin %> contacts --export # GDPR CSV → contacts.csv',
13
14
  '<%= config.bin %> contacts --delete sarah@example.com',
14
15
  ];
15
16
  static flags = {
@@ -112,14 +113,14 @@ export default class Contacts extends BaseCommand {
112
113
  this.log(`\n Export status: ${status ?? 'unknown'}. No download URL yet.\n`);
113
114
  return;
114
115
  }
115
- const url = `https://${downloadUrl}`;
116
- const fileResult = await this.apiClient.getFile(url);
116
+ const fileResult = await this.apiClient.getPublicFile(downloadUrl.trim());
117
117
  if (!fileResult.ok) {
118
118
  this.error(`Download failed: ${fileResult.status} ${fileResult.error ?? ''}\n` +
119
119
  ` URL: ${fileResult.debug.fullUrl}`);
120
120
  }
121
- await writeFile('contacts.csv', Buffer.from(fileResult.body));
122
- this.log(`\n ${chalk.green('✓')} Contact export saved to ${chalk.cyan('contacts.csv')}\n`);
121
+ const outputPath = resolve('contacts.csv');
122
+ await writeFile(outputPath, Buffer.from(fileResult.body));
123
+ this.log(`\n ${chalk.green('✓')} Contact export saved to ${chalk.cyan(outputPath)}\n`);
123
124
  }
124
125
  /**
125
126
  * Performs a GDPR-compliant hard delete of a contact and all their
@@ -73,9 +73,7 @@ export default class Deploy extends BaseCommand {
73
73
  return {
74
74
  condition: email.condition || null,
75
75
  ctaText: email.ctaText || '',
76
- delay: typeof email.delay === 'string'
77
- ? Number.parseInt(email.delay, 10) || 0
78
- : email.delay,
76
+ delay: email.delay,
79
77
  goal: email.goal || '',
80
78
  id: email.id,
81
79
  isReminder: false,
@@ -23,5 +23,6 @@ export default class Domain extends BaseCommand {
23
23
  * bounce rate, and spam complaint rate.
24
24
  */
25
25
  private showDomainStatus;
26
+ private collectDomainInputs;
26
27
  private recordLabel;
27
28
  }
@@ -45,69 +45,22 @@ export default class Domain extends BaseCommand {
45
45
  this.log(`\n ${'─'.repeat(53)}`);
46
46
  this.log(` ${chalk.bold('DOMAIN SETUP')}`);
47
47
  this.log(` ${'─'.repeat(53)}\n`);
48
- let domain;
49
- let senderEmail;
50
- let fromName;
51
- let replyTo;
52
- let address;
53
- if (flags.yes) {
54
- domain = yamlConfig.project?.domain || '';
55
- senderEmail = yamlConfig.project?.fromEmail || '';
56
- fromName = yamlConfig.project?.fromName || '';
57
- replyTo = yamlConfig.project?.replyTo || '';
58
- address = yamlConfig.project?.address || '';
59
- if (!domain) {
60
- this.error('Domain is required. Set it in mailmodo.yaml or use interactive mode.');
61
- }
62
- }
63
- else {
64
- domain = await input({
65
- default: yamlConfig.project?.domain,
66
- message: 'What domain will you send from?',
67
- validate: (v) => (v?.trim() ? true : 'Domain is required'),
68
- });
69
- senderEmail = await input({
70
- default: yamlConfig.project?.fromEmail,
71
- message: 'Sender email address:',
72
- validate: (v) => v?.includes('@') ? true : 'Please enter a valid email',
73
- });
74
- fromName = await input({
75
- default: yamlConfig.project?.fromName || '',
76
- message: 'Display name (optional, shown as sender name):',
77
- });
78
- replyTo = await input({
79
- default: yamlConfig.project?.replyTo || '',
80
- message: 'Reply-to address (optional, press Enter to use sender email):',
81
- });
82
- address = await input({
83
- default: yamlConfig.project?.address,
84
- message: 'Business address (required by law):',
85
- validate: (v) => (v?.trim() ? true : 'Address is required'),
86
- });
87
- }
88
- const apiPayload = {
48
+ const { domain, senderEmail, address } = await this.collectDomainInputs(flags.yes, yamlConfig);
49
+ const response = await this.withApiSpinner({ json: flags.json, text: ' Configuring domain...' }, () => this.apiClient.post(API_ENDPOINTS.DOMAIN, {
89
50
  address,
90
51
  domain,
91
52
  fromEmail: senderEmail,
92
- };
93
- if (fromName)
94
- apiPayload.fromName = fromName;
95
- if (replyTo)
96
- apiPayload.replyTo = replyTo;
97
- const response = await this.withApiSpinner({ json: flags.json, text: ' Configuring domain...' }, () => this.apiClient.post(API_ENDPOINTS.DOMAIN, apiPayload));
53
+ }));
98
54
  if (!response.ok) {
99
55
  this.handleApiError(response);
100
56
  }
101
57
  yamlConfig.project.domain = domain;
102
58
  yamlConfig.project.fromEmail = senderEmail;
103
59
  yamlConfig.project.address = address;
104
- if (fromName)
105
- yamlConfig.project.fromName = fromName;
106
- if (replyTo)
107
- yamlConfig.project.replyTo = replyTo;
108
60
  await saveYaml(yamlConfig);
109
61
  await saveConfig({ ...config, domain });
110
62
  const records = response.data?.dnsRecords || [];
63
+ const guideUrl = response.data?.dnsGuideUrl ?? DNS_GUIDE_URL;
111
64
  if (flags.json) {
112
65
  this.log(JSON.stringify({ dnsRecords: records, domain }, null, 2));
113
66
  return;
@@ -120,7 +73,7 @@ export default class Domain extends BaseCommand {
120
73
  this.log(` Value: ${record.value}\n`);
121
74
  }
122
75
  this.log(` DNS changes take 5–30 minutes to propagate.`);
123
- this.log(` Full guide: ${chalk.cyan(DNS_GUIDE_URL)}\n`);
76
+ this.log(` Full guide: ${chalk.cyan(guideUrl)}\n`);
124
77
  if (!flags.yes) {
125
78
  const action = await input({
126
79
  default: '',
@@ -199,6 +152,35 @@ export default class Domain extends BaseCommand {
199
152
  this.log(` Bounce rate: ${data.bounceRate ?? 'N/A'}%`);
200
153
  this.log(` Spam rate: ${data.spamRate ?? 'N/A'}%\n`);
201
154
  }
155
+ async collectDomainInputs(skipPrompts, yamlConfig) {
156
+ if (skipPrompts) {
157
+ const domain = yamlConfig.project?.domain || '';
158
+ if (!domain) {
159
+ this.error('Domain is required. Set it in mailmodo.yaml or use interactive mode.');
160
+ }
161
+ return {
162
+ address: yamlConfig.project?.address || '',
163
+ domain,
164
+ senderEmail: yamlConfig.project?.fromEmail || '',
165
+ };
166
+ }
167
+ const domain = await input({
168
+ default: yamlConfig.project?.domain,
169
+ message: 'What domain will you send from?',
170
+ validate: (v) => (v?.trim() ? true : 'Domain is required'),
171
+ });
172
+ const senderEmail = await input({
173
+ default: yamlConfig.project?.fromEmail,
174
+ message: 'Sender email address:',
175
+ validate: (v) => (v?.includes('@') ? true : 'Please enter a valid email'),
176
+ });
177
+ const address = await input({
178
+ default: yamlConfig.project?.address,
179
+ message: 'Business address (required by law):',
180
+ validate: (v) => (v?.trim() ? true : 'Address is required'),
181
+ });
182
+ return { address, domain, senderEmail };
183
+ }
202
184
  recordLabel(index) {
203
185
  const labels = ['DKIM', 'DMARC', 'Return Path'];
204
186
  return labels[index] || `Record ${index + 1}`;
@@ -44,6 +44,10 @@ export declare class ApiClient {
44
44
  * Bearer auth as other requests; does not parse JSON.
45
45
  */
46
46
  getFile(url: string): Promise<FileFetchResult>;
47
+ /**
48
+ * GET an external URL (e.g. blob storage) without auth headers.
49
+ */
50
+ getPublicFile(url: string): Promise<FileFetchResult>;
47
51
  patch<T = Record<string, unknown>>(path: string, body?: Record<string, unknown>): Promise<ApiResponse<T>>;
48
52
  post<T = Record<string, unknown>>(path: string, body?: Record<string, unknown> | unknown): Promise<ApiResponse<T>>;
49
53
  postFormData<T = Record<string, unknown>>(path: string, formData: FormData): Promise<ApiResponse<T>>;
@@ -1,5 +1,5 @@
1
1
  import { API_BASE_URL } from './constants.js';
2
- import { fetchFileWithBearerAuth, } from './fetch-file.js';
2
+ import { fetchFileNoAuth, fetchFileWithBearerAuth, } from './fetch-file.js';
3
3
  /**
4
4
  * HTTP client for the Mailmodo CLI API.
5
5
  * Wraps the native fetch API with Bearer token authentication,
@@ -123,6 +123,12 @@ export class ApiClient {
123
123
  async getFile(url) {
124
124
  return fetchFileWithBearerAuth(url, this.apiKey);
125
125
  }
126
+ /**
127
+ * GET an external URL (e.g. blob storage) without auth headers.
128
+ */
129
+ async getPublicFile(url) {
130
+ return fetchFileNoAuth(url);
131
+ }
126
132
  async patch(path, body) {
127
133
  return this.request('PATCH', path, body);
128
134
  }
@@ -5,6 +5,8 @@ export declare const API_ENDPOINTS: Readonly<{
5
5
  ASSETS_LOGO: "/assets/logo";
6
6
  AUTH_VALIDATE: "/auth/validate";
7
7
  BILLING_CAP: "/billing/cap";
8
+ BILLING_CHECKOUT: "/billing/checkout";
9
+ BILLING_PURCHASE: "/billing/purchase";
8
10
  BILLING_STATUS: "/billing/status";
9
11
  CONTACTS: "/contacts";
10
12
  CONTACTS_EXPORT: "/contacts/export";
@@ -11,6 +11,8 @@ export const API_ENDPOINTS = Object.freeze({
11
11
  ASSETS_LOGO: '/assets/logo',
12
12
  AUTH_VALIDATE: '/auth/validate',
13
13
  BILLING_CAP: '/billing/cap',
14
+ BILLING_CHECKOUT: '/billing/checkout',
15
+ BILLING_PURCHASE: '/billing/purchase',
14
16
  BILLING_STATUS: '/billing/status',
15
17
  CONTACTS: '/contacts',
16
18
  CONTACTS_EXPORT: '/contacts/export',
@@ -12,3 +12,7 @@ export interface FileFetchResult {
12
12
  * does not parse JSON.
13
13
  */
14
14
  export declare function fetchFileWithBearerAuth(url: string, apiKey: string): Promise<FileFetchResult>;
15
+ /**
16
+ * GET an absolute external URL (e.g. blob storage) without auth headers.
17
+ */
18
+ export declare function fetchFileNoAuth(url: string): Promise<FileFetchResult>;
@@ -1,31 +1,8 @@
1
1
  const USER_AGENT = '@mailmodo/cli';
2
- /**
3
- * GET an absolute URL and return the raw body (e.g. CSV). Uses Bearer auth;
4
- * does not parse JSON.
5
- */
6
- export async function fetchFileWithBearerAuth(url, apiKey) {
7
- let href;
8
- try {
9
- href = new URL(url.trim()).toString();
10
- }
11
- catch {
12
- return {
13
- body: new ArrayBuffer(0),
14
- debug: { fullUrl: url.trim() },
15
- error: 'Invalid URL',
16
- ok: false,
17
- status: 0,
18
- };
19
- }
2
+ async function doFetch(href, headers) {
20
3
  const debug = { fullUrl: href };
21
4
  try {
22
- const response = await fetch(href, {
23
- headers: {
24
- Authorization: `Bearer ${apiKey}`,
25
- 'User-Agent': USER_AGENT,
26
- },
27
- method: 'GET',
28
- });
5
+ const response = await fetch(href, { headers, method: 'GET' });
29
6
  const body = await response.arrayBuffer();
30
7
  if (!response.ok) {
31
8
  return {
@@ -49,3 +26,47 @@ export async function fetchFileWithBearerAuth(url, apiKey) {
49
26
  };
50
27
  }
51
28
  }
29
+ function parseUrl(url) {
30
+ try {
31
+ return new URL(url.trim()).toString();
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ /**
38
+ * GET an absolute URL and return the raw body (e.g. CSV). Uses Bearer auth;
39
+ * does not parse JSON.
40
+ */
41
+ export async function fetchFileWithBearerAuth(url, apiKey) {
42
+ const href = parseUrl(url);
43
+ if (!href) {
44
+ return {
45
+ body: new ArrayBuffer(0),
46
+ debug: { fullUrl: url.trim() },
47
+ error: 'Invalid URL',
48
+ ok: false,
49
+ status: 0,
50
+ };
51
+ }
52
+ return doFetch(href, {
53
+ Authorization: `Bearer ${apiKey}`,
54
+ 'User-Agent': USER_AGENT,
55
+ });
56
+ }
57
+ /**
58
+ * GET an absolute external URL (e.g. blob storage) without auth headers.
59
+ */
60
+ export async function fetchFileNoAuth(url) {
61
+ const href = parseUrl(url);
62
+ if (!href) {
63
+ return {
64
+ body: new ArrayBuffer(0),
65
+ debug: { fullUrl: url.trim() },
66
+ error: 'Invalid URL',
67
+ ok: false,
68
+ status: 0,
69
+ };
70
+ }
71
+ return doFetch(href, { 'User-Agent': USER_AGENT });
72
+ }
@@ -3,11 +3,14 @@
3
3
  "billing": {
4
4
  "aliases": [],
5
5
  "args": {},
6
- "description": "View billing status, manage payment, and set spending cap",
6
+ "description": "View billing status, purchase blocks, set cap, or add a payment method",
7
7
  "examples": [
8
8
  "<%= config.bin %> billing",
9
9
  "<%= config.bin %> billing --status",
10
- "<%= config.bin %> billing --cap 5"
10
+ "<%= config.bin %> billing --cap 5",
11
+ "<%= config.bin %> billing --cap 5 --auto-charge-block-count 2",
12
+ "<%= config.bin %> billing --purchase 3",
13
+ "<%= config.bin %> billing --checkout"
11
14
  ],
12
15
  "flags": {
13
16
  "json": {
@@ -23,13 +26,33 @@
23
26
  "allowNo": false,
24
27
  "type": "boolean"
25
28
  },
29
+ "auto-charge-block-count": {
30
+ "description": "Blocks to auto-purchase when the quota runs low (use with --cap)",
31
+ "name": "auto-charge-block-count",
32
+ "hasDynamicHelp": false,
33
+ "multiple": false,
34
+ "type": "option"
35
+ },
26
36
  "cap": {
27
- "description": "Set monthly block cap (max blocks to auto-charge)",
37
+ "description": "Set monthly sending cap in blocks",
28
38
  "name": "cap",
29
39
  "hasDynamicHelp": false,
30
40
  "multiple": false,
31
41
  "type": "option"
32
42
  },
43
+ "checkout": {
44
+ "description": "Open Stripe checkout to add or update a payment method",
45
+ "name": "checkout",
46
+ "allowNo": false,
47
+ "type": "boolean"
48
+ },
49
+ "purchase": {
50
+ "description": "Manually purchase this many 10,000-email blocks",
51
+ "name": "purchase",
52
+ "hasDynamicHelp": false,
53
+ "multiple": false,
54
+ "type": "option"
55
+ },
33
56
  "status": {
34
57
  "description": "Show billing status only",
35
58
  "name": "status",
@@ -419,15 +442,19 @@
419
442
  "index.js"
420
443
  ]
421
444
  },
422
- "logs": {
445
+ "preview": {
423
446
  "aliases": [],
424
- "args": {},
425
- "description": "View email send logs and delivery events",
447
+ "args": {
448
+ "id": {
449
+ "description": "Email template ID to preview",
450
+ "name": "id"
451
+ }
452
+ },
453
+ "description": "Preview an email in browser, as text, or send a test",
426
454
  "examples": [
427
- "<%= config.bin %> logs",
428
- "<%= config.bin %> logs --email sarah@example.com",
429
- "<%= config.bin %> logs --failed",
430
- "<%= config.bin %> logs --json"
455
+ "<%= config.bin %> preview welcome",
456
+ "<%= config.bin %> preview welcome --text",
457
+ "<%= config.bin %> preview welcome --send me@example.com"
431
458
  ],
432
459
  "flags": {
433
460
  "json": {
@@ -443,39 +470,23 @@
443
470
  "allowNo": false,
444
471
  "type": "boolean"
445
472
  },
446
- "email": {
447
- "description": "Filter logs by contact email",
448
- "name": "email",
473
+ "send": {
474
+ "description": "Send test email to this address",
475
+ "name": "send",
449
476
  "hasDynamicHelp": false,
450
477
  "multiple": false,
451
478
  "type": "option"
452
479
  },
453
- "failed": {
454
- "description": "Show only failed/bounced events",
455
- "name": "failed",
480
+ "text": {
481
+ "description": "Output plain text version (for AI agents)",
482
+ "name": "text",
456
483
  "allowNo": false,
457
484
  "type": "boolean"
458
- },
459
- "limit": {
460
- "description": "Entries per page (max 200)",
461
- "name": "limit",
462
- "default": 50,
463
- "hasDynamicHelp": false,
464
- "multiple": false,
465
- "type": "option"
466
- },
467
- "page": {
468
- "description": "Page number",
469
- "name": "page",
470
- "default": 1,
471
- "hasDynamicHelp": false,
472
- "multiple": false,
473
- "type": "option"
474
485
  }
475
486
  },
476
487
  "hasDynamicHelp": false,
477
488
  "hiddenAliases": [],
478
- "id": "logs",
489
+ "id": "preview",
479
490
  "pluginAlias": "@mailmodo/cli",
480
491
  "pluginName": "@mailmodo/cli",
481
492
  "pluginType": "core",
@@ -485,23 +496,18 @@
485
496
  "relativePath": [
486
497
  "dist",
487
498
  "commands",
488
- "logs",
499
+ "preview",
489
500
  "index.js"
490
501
  ]
491
502
  },
492
- "preview": {
503
+ "settings": {
493
504
  "aliases": [],
494
- "args": {
495
- "id": {
496
- "description": "Email template ID to preview",
497
- "name": "id"
498
- }
499
- },
500
- "description": "Preview an email in browser, as text, or send a test",
505
+ "args": {},
506
+ "description": "View and update project settings",
501
507
  "examples": [
502
- "<%= config.bin %> preview welcome",
503
- "<%= config.bin %> preview welcome --text",
504
- "<%= config.bin %> preview welcome --send me@example.com"
508
+ "<%= config.bin %> settings",
509
+ "<%= config.bin %> settings --set brand_color=#0F3460",
510
+ "<%= config.bin %> settings --json"
505
511
  ],
506
512
  "flags": {
507
513
  "json": {
@@ -517,23 +523,17 @@
517
523
  "allowNo": false,
518
524
  "type": "boolean"
519
525
  },
520
- "send": {
521
- "description": "Send test email to this address",
522
- "name": "send",
526
+ "set": {
527
+ "description": "Set a setting (format: key=value)",
528
+ "name": "set",
523
529
  "hasDynamicHelp": false,
524
530
  "multiple": false,
525
531
  "type": "option"
526
- },
527
- "text": {
528
- "description": "Output plain text version (for AI agents)",
529
- "name": "text",
530
- "allowNo": false,
531
- "type": "boolean"
532
532
  }
533
533
  },
534
534
  "hasDynamicHelp": false,
535
535
  "hiddenAliases": [],
536
- "id": "preview",
536
+ "id": "settings",
537
537
  "pluginAlias": "@mailmodo/cli",
538
538
  "pluginName": "@mailmodo/cli",
539
539
  "pluginType": "core",
@@ -543,17 +543,19 @@
543
543
  "relativePath": [
544
544
  "dist",
545
545
  "commands",
546
- "preview",
546
+ "settings",
547
547
  "index.js"
548
548
  ]
549
549
  },
550
- "status": {
550
+ "logs": {
551
551
  "aliases": [],
552
552
  "args": {},
553
- "description": "View email performance metrics and quota usage",
553
+ "description": "View email send logs and delivery events",
554
554
  "examples": [
555
- "<%= config.bin %> status",
556
- "<%= config.bin %> status --json"
555
+ "<%= config.bin %> logs",
556
+ "<%= config.bin %> logs --email sarah@example.com",
557
+ "<%= config.bin %> logs --failed",
558
+ "<%= config.bin %> logs --json"
557
559
  ],
558
560
  "flags": {
559
561
  "json": {
@@ -568,11 +570,40 @@
568
570
  "name": "yes",
569
571
  "allowNo": false,
570
572
  "type": "boolean"
573
+ },
574
+ "email": {
575
+ "description": "Filter logs by contact email",
576
+ "name": "email",
577
+ "hasDynamicHelp": false,
578
+ "multiple": false,
579
+ "type": "option"
580
+ },
581
+ "failed": {
582
+ "description": "Show only failed/bounced events",
583
+ "name": "failed",
584
+ "allowNo": false,
585
+ "type": "boolean"
586
+ },
587
+ "limit": {
588
+ "description": "Entries per page (max 200)",
589
+ "name": "limit",
590
+ "default": 50,
591
+ "hasDynamicHelp": false,
592
+ "multiple": false,
593
+ "type": "option"
594
+ },
595
+ "page": {
596
+ "description": "Page number",
597
+ "name": "page",
598
+ "default": 1,
599
+ "hasDynamicHelp": false,
600
+ "multiple": false,
601
+ "type": "option"
571
602
  }
572
603
  },
573
604
  "hasDynamicHelp": false,
574
605
  "hiddenAliases": [],
575
- "id": "status",
606
+ "id": "logs",
576
607
  "pluginAlias": "@mailmodo/cli",
577
608
  "pluginName": "@mailmodo/cli",
578
609
  "pluginType": "core",
@@ -582,18 +613,17 @@
582
613
  "relativePath": [
583
614
  "dist",
584
615
  "commands",
585
- "status",
616
+ "logs",
586
617
  "index.js"
587
618
  ]
588
619
  },
589
- "settings": {
620
+ "status": {
590
621
  "aliases": [],
591
622
  "args": {},
592
- "description": "View and update project settings",
623
+ "description": "View email performance metrics and quota usage",
593
624
  "examples": [
594
- "<%= config.bin %> settings",
595
- "<%= config.bin %> settings --set brand_color=#0F3460",
596
- "<%= config.bin %> settings --json"
625
+ "<%= config.bin %> status",
626
+ "<%= config.bin %> status --json"
597
627
  ],
598
628
  "flags": {
599
629
  "json": {
@@ -608,18 +638,11 @@
608
638
  "name": "yes",
609
639
  "allowNo": false,
610
640
  "type": "boolean"
611
- },
612
- "set": {
613
- "description": "Set a setting (format: key=value)",
614
- "name": "set",
615
- "hasDynamicHelp": false,
616
- "multiple": false,
617
- "type": "option"
618
641
  }
619
642
  },
620
643
  "hasDynamicHelp": false,
621
644
  "hiddenAliases": [],
622
- "id": "settings",
645
+ "id": "status",
623
646
  "pluginAlias": "@mailmodo/cli",
624
647
  "pluginName": "@mailmodo/cli",
625
648
  "pluginType": "core",
@@ -629,10 +652,10 @@
629
652
  "relativePath": [
630
653
  "dist",
631
654
  "commands",
632
- "settings",
655
+ "status",
633
656
  "index.js"
634
657
  ]
635
658
  }
636
659
  },
637
- "version": "0.0.26-beta.pr29.44"
660
+ "version": "0.0.27-beta.pr30.46"
638
661
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mailmodo/cli",
3
3
  "description": "Email lifecycle automation for the AI-native builder generation.",
4
- "version": "0.0.26-beta.pr29.44",
4
+ "version": "0.0.27-beta.pr30.46",
5
5
  "author": "provishalk",
6
6
  "bin": {
7
7
  "mailmodo": "bin/run.js"