@mailmodo/cli 0.0.49 → 0.0.50-beta.pr52.80

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,12 +3,30 @@ export default class Deploy extends BaseCommand {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
+ pause: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ resume: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
6
8
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
9
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
10
  };
9
11
  private fetchDomainVerifyForDeploy;
10
12
  run(): Promise<void>;
11
13
  private validateSequence;
14
+ /**
15
+ * Calls `POST /sequences/{id}/status` with `{ status: "paused" }` and prints
16
+ * a confirmation-aware success/no-op message. Skips the prompt entirely when
17
+ * `--yes` is set so the command stays scriptable. `--json` always emits the
18
+ * raw server payload (sequenceId, status, alreadyInStatus).
19
+ */
20
+ private pauseSequence;
21
+ /**
22
+ * Calls `POST /sequences/{id}/status` with `{ status: "active" }`. No prompt
23
+ * — resuming is the safe direction (it does not start sends that weren't
24
+ * already queued). `--json` always emits the raw server payload (sequenceId,
25
+ * status, alreadyInStatus).
26
+ */
27
+ private resumeSequence;
28
+ private updateSequenceStatus;
29
+ private sequenceStatusPath;
12
30
  private buildDeployPayload;
13
31
  private resolveMonthlyCapForDeploy;
14
32
  private mapEmailToPayload;
@@ -1,17 +1,28 @@
1
+ import { Flags } from '@oclif/core';
1
2
  import { confirm, input } from '@inquirer/prompts';
2
3
  import chalk from 'chalk';
3
4
  import { BaseCommand } from '../../lib/base-command.js';
4
5
  import { API_ENDPOINTS, DEFAULT_BRAND_COLOR } from '../../lib/constants.js';
5
- import { ERRORS, INFO, PROMPTS, SEPARATOR } from '../../lib/messages.js';
6
+ import { ERRORS, INFO, pauseAlready, pauseSuccess, PROMPTS, resumeAlready, resumeSuccess, SEPARATOR, } from '../../lib/messages.js';
6
7
  import { loadTemplate, } from '../../lib/yaml-config.js';
7
8
  export default class Deploy extends BaseCommand {
8
- static description = 'Deploy email sequences and verify sending domain';
9
+ static description = 'Deploy, pause, or resume an email sequence';
9
10
  static examples = [
10
11
  '<%= config.bin %> deploy',
11
12
  '<%= config.bin %> deploy --yes',
13
+ '<%= config.bin %> deploy --pause seq_abc123',
14
+ '<%= config.bin %> deploy --resume seq_abc123 --json',
12
15
  ];
13
16
  static flags = {
14
17
  ...BaseCommand.baseFlags,
18
+ pause: Flags.string({
19
+ description: 'Pause a deployed sequence by ID (stops scheduled + triggered sends)',
20
+ exclusive: ['resume'],
21
+ }),
22
+ resume: Flags.string({
23
+ description: 'Resume a paused sequence by ID',
24
+ exclusive: ['pause'],
25
+ }),
15
26
  };
16
27
  fetchDomainVerifyForDeploy(jsonOutput, domain) {
17
28
  return this.withApiSpinner({ json: jsonOutput, text: ' Checking domain verification...' }, () => this.apiClient.get(API_ENDPOINTS.DOMAIN_VERIFY, {
@@ -21,6 +32,15 @@ export default class Deploy extends BaseCommand {
21
32
  async run() {
22
33
  const { flags } = await this.parse(Deploy);
23
34
  await this.ensureAuth();
35
+ const baseFlags = { json: flags.json, yes: flags.yes };
36
+ if (flags.pause) {
37
+ await this.pauseSequence(flags.pause, baseFlags);
38
+ return;
39
+ }
40
+ if (flags.resume) {
41
+ await this.resumeSequence(flags.resume, baseFlags);
42
+ return;
43
+ }
24
44
  const yamlConfig = await this.ensureYaml();
25
45
  const domainReady = await this.ensureDomainReady(yamlConfig, flags);
26
46
  if (!domainReady)
@@ -60,6 +80,60 @@ export default class Deploy extends BaseCommand {
60
80
  }
61
81
  return response.data;
62
82
  }
83
+ /**
84
+ * Calls `POST /sequences/{id}/status` with `{ status: "paused" }` and prints
85
+ * a confirmation-aware success/no-op message. Skips the prompt entirely when
86
+ * `--yes` is set so the command stays scriptable. `--json` always emits the
87
+ * raw server payload (sequenceId, status, alreadyInStatus).
88
+ */
89
+ async pauseSequence(sequenceId, flags) {
90
+ if (!flags.yes) {
91
+ const confirmed = await confirm({
92
+ default: false,
93
+ message: PROMPTS.PAUSE_CONFIRM,
94
+ });
95
+ if (!confirmed) {
96
+ this.log(`\n ${INFO.PAUSE_CANCELLED}\n`);
97
+ return;
98
+ }
99
+ }
100
+ const data = await this.updateSequenceStatus(sequenceId, 'paused', flags, ' Pausing sequence...');
101
+ if (flags.json) {
102
+ this.log(JSON.stringify(data, null, 2));
103
+ return;
104
+ }
105
+ const message = data.alreadyInStatus
106
+ ? pauseAlready(data.sequenceId || sequenceId)
107
+ : pauseSuccess(data.sequenceId || sequenceId);
108
+ this.log(`\n ${message}\n`);
109
+ }
110
+ /**
111
+ * Calls `POST /sequences/{id}/status` with `{ status: "active" }`. No prompt
112
+ * — resuming is the safe direction (it does not start sends that weren't
113
+ * already queued). `--json` always emits the raw server payload (sequenceId,
114
+ * status, alreadyInStatus).
115
+ */
116
+ async resumeSequence(sequenceId, flags) {
117
+ const data = await this.updateSequenceStatus(sequenceId, 'active', flags, ' Resuming sequence...');
118
+ if (flags.json) {
119
+ this.log(JSON.stringify(data, null, 2));
120
+ return;
121
+ }
122
+ const message = data.alreadyInStatus
123
+ ? resumeAlready(data.sequenceId || sequenceId)
124
+ : resumeSuccess(data.sequenceId || sequenceId);
125
+ this.log(`\n ${message}\n`);
126
+ }
127
+ async updateSequenceStatus(sequenceId, status, flags, spinnerText) {
128
+ const response = await this.withApiSpinner({ json: flags.json, text: spinnerText }, () => this.apiClient.post(this.sequenceStatusPath(sequenceId), { status }));
129
+ if (!response.ok) {
130
+ this.handleApiError(response);
131
+ }
132
+ return response.data;
133
+ }
134
+ sequenceStatusPath(sequenceId) {
135
+ return `${API_ENDPOINTS.SEQUENCES}/${encodeURIComponent(sequenceId)}/status`;
136
+ }
63
137
  async buildDeployPayload(yamlConfig) {
64
138
  const [emailsWithHtml, monthlyCap] = await Promise.all([
65
139
  Promise.all(yamlConfig.emails.map(async (email) => {
@@ -0,0 +1,14 @@
1
+ import { BaseCommand } from '../../lib/base-command.js';
2
+ export default class Deployments extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ };
9
+ run(): Promise<void>;
10
+ private renderTable;
11
+ private statusColor;
12
+ private colWidth;
13
+ private formatDate;
14
+ }
@@ -0,0 +1,76 @@
1
+ import chalk from 'chalk';
2
+ import { BaseCommand } from '../../lib/base-command.js';
3
+ import { API_ENDPOINTS } from '../../lib/constants.js';
4
+ export default class Deployments extends BaseCommand {
5
+ static description = 'List every deployed sequence on this account, with the IDs needed for deploy --pause / --resume';
6
+ static examples = [
7
+ '<%= config.bin %> deployments',
8
+ '<%= config.bin %> deployments --json',
9
+ ];
10
+ static flags = {
11
+ ...BaseCommand.baseFlags,
12
+ };
13
+ async run() {
14
+ const { flags } = await this.parse(Deployments);
15
+ await this.ensureAuth();
16
+ const response = await this.withApiSpinner({ json: flags.json, text: ' Loading deployments...' }, () => this.apiClient.get(API_ENDPOINTS.SEQUENCES));
17
+ if (!response.ok) {
18
+ this.handleApiError(response);
19
+ }
20
+ if (flags.json) {
21
+ this.log(JSON.stringify(response.data, null, 2));
22
+ return;
23
+ }
24
+ this.renderTable(response.data);
25
+ }
26
+ renderTable(data) {
27
+ const sequences = data.sequences ?? [];
28
+ if (sequences.length === 0) {
29
+ this.log(`\n ${chalk.dim('No deployed sequences yet.')}`);
30
+ this.log(` Run ${chalk.cyan('mailmodo deploy')} to deploy one.\n`);
31
+ return;
32
+ }
33
+ const rows = sequences.map((seq) => ({
34
+ emails: String(seq.emailCount ?? 0),
35
+ product: seq.productName ?? '',
36
+ sequenceId: seq.sequenceId ?? '',
37
+ status: seq.status ?? '',
38
+ updated: this.formatDate(seq.updatedAt),
39
+ }));
40
+ const widths = {
41
+ emails: this.colWidth(rows, 'emails', 'Emails'),
42
+ product: this.colWidth(rows, 'product', 'Product'),
43
+ sequenceId: this.colWidth(rows, 'sequenceId', 'Sequence ID'),
44
+ status: this.colWidth(rows, 'status', 'Status'),
45
+ updated: this.colWidth(rows, 'updated', 'Updated'),
46
+ };
47
+ this.log(`\n ${chalk.bold(String(sequences.length))} deployed ${sequences.length === 1 ? 'sequence' : 'sequences'}:\n`);
48
+ this.log(` ${chalk.bold('Product'.padEnd(widths.product))}${chalk.bold('Status'.padEnd(widths.status))}${chalk.bold('Emails'.padEnd(widths.emails))}${chalk.bold('Sequence ID'.padEnd(widths.sequenceId))}${chalk.bold('Updated')}`);
49
+ this.log(` ${'─'.repeat(widths.product + widths.status + widths.emails + widths.sequenceId + widths.updated)}`);
50
+ for (const row of rows) {
51
+ const status = this.statusColor(row.status)(row.status.padEnd(widths.status));
52
+ this.log(` ${row.product.padEnd(widths.product)}${status}${row.emails.padEnd(widths.emails)}${chalk.cyan(row.sequenceId.padEnd(widths.sequenceId))}${chalk.dim(row.updated)}`);
53
+ }
54
+ this.log('');
55
+ this.log(` Pause: ${chalk.cyan('mailmodo deploy --pause <sequence-id>')}`);
56
+ this.log(` Resume: ${chalk.cyan('mailmodo deploy --resume <sequence-id>')}\n`);
57
+ }
58
+ statusColor(status) {
59
+ if (status === 'active')
60
+ return chalk.green;
61
+ if (status === 'paused')
62
+ return chalk.yellow;
63
+ return chalk.white;
64
+ }
65
+ colWidth(rows, key, header) {
66
+ return Math.max(...rows.map((r) => r[key].length), header.length) + 2;
67
+ }
68
+ formatDate(iso) {
69
+ if (!iso)
70
+ return '';
71
+ const parsed = new Date(iso);
72
+ if (Number.isNaN(parsed.getTime()))
73
+ return iso;
74
+ return parsed.toISOString().slice(0, 10);
75
+ }
76
+ }
@@ -49,6 +49,7 @@ export default class Edit extends BaseCommand {
49
49
  async runEditStep(ctx, changeDescription, flags) {
50
50
  const response = await this.withApiSpinner({ json: flags.json, text: ' Applying AI edits...' }, () => this.callEditApi(changeDescription, ctx.email, ctx.templateHtml));
51
51
  if (!response.ok) {
52
+ this.handleAiQuotaError(response, 'edit');
52
53
  this.handleApiError(response);
53
54
  }
54
55
  const updated = response.data;
@@ -8,5 +8,6 @@ export default class Init extends BaseCommand {
8
8
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
9
  };
10
10
  run(): Promise<void>;
11
+ private persistMonthlyCap;
11
12
  private confirmOverwriteIfNeeded;
12
13
  }
@@ -64,6 +64,7 @@ export default class Init extends BaseCommand {
64
64
  url: productUrl,
65
65
  }));
66
66
  if (!analysisResponse.ok) {
67
+ this.handleAiQuotaError(analysisResponse, 'init');
67
68
  this.handleApiError(analysisResponse);
68
69
  }
69
70
  const analysis = analysisResponse.data;
@@ -154,12 +155,7 @@ export default class Init extends BaseCommand {
154
155
  },
155
156
  };
156
157
  await saveYaml(yamlConfig);
157
- const billingStatus = await this.fetchBillingStatus();
158
- const monthlyCap = billingStatus?.cap?.inBlocks;
159
- if (monthlyCap !== null && monthlyCap !== undefined) {
160
- yamlConfig.project.monthlyCap = monthlyCap;
161
- await saveYaml(yamlConfig);
162
- }
158
+ await this.persistMonthlyCap(yamlConfig);
163
159
  const templateSaves = analysisPayload.recommendedEmails.flatMap((rec, index) => {
164
160
  const generated = generatedEmails[index];
165
161
  const saves = [];
@@ -184,6 +180,14 @@ export default class Init extends BaseCommand {
184
180
  this.log(` Created ${chalk.green('mailmodo.yaml')} + ${chalk.green(String(emailConfigs.length))} email templates in ${chalk.green('/mailmodo')}\n`);
185
181
  this.log(` Run ${chalk.cyan("'mailmodo emails'")} to review.\n`);
186
182
  }
183
+ async persistMonthlyCap(yamlConfig) {
184
+ const billingStatus = await this.fetchBillingStatus();
185
+ const monthlyCap = billingStatus?.cap?.inBlocks;
186
+ if (monthlyCap !== null && monthlyCap !== undefined) {
187
+ yamlConfig.project.monthlyCap = monthlyCap;
188
+ await saveYaml(yamlConfig);
189
+ }
190
+ }
187
191
  async confirmOverwriteIfNeeded(flags) {
188
192
  const existing = await loadYaml();
189
193
  if (!existing)
@@ -2,6 +2,11 @@ import { Command } from '@oclif/core';
2
2
  import { ApiClient, type ApiRequestDebugInfo } from './api-client.js';
3
3
  import { type MailmodoConfig } from './config.js';
4
4
  import { type MailmodoYaml } from './yaml-config.js';
5
+ /**
6
+ * Kind of AI quota that was exhausted. Drives both the headline default
7
+ * message and the follow-up tip pointing the user at the next-best action.
8
+ */
9
+ export type AiQuotaFeature = 'edit' | 'init';
5
10
  export interface BillingCapUpdateResult {
6
11
  autoChargeBlockCount?: number;
7
12
  capBlocks: number;
@@ -76,6 +81,25 @@ export declare abstract class BaseCommand extends Command {
76
81
  error?: string;
77
82
  status: number;
78
83
  }): never;
84
+ /**
85
+ * Intercepts a 429 "Monthly quota exhausted" response from an AI rate-limited
86
+ * endpoint (analyze for `init`, email edit for `edit`) and exits with a
87
+ * feature-specific message that includes the server's error text, the reset
88
+ * date, optional retry hint, and the next-best action. Returns without
89
+ * exiting when the response is not a 429 so callers can fall through to
90
+ * `handleApiError` for non-quota failures.
91
+ *
92
+ * @param {{ status: number; error?: string; data?: unknown; debug?: ApiRequestDebugInfo }} response - The failed API response.
93
+ * Must carry the parsed JSON body (`data`) so reset/retry fields can be surfaced.
94
+ * @param {AiQuotaFeature} feature - Which AI quota was hit. Drives the default headline + tip
95
+ * shown when the server omits an explicit `error` string.
96
+ */
97
+ protected handleAiQuotaError(response: {
98
+ data?: unknown;
99
+ debug?: ApiRequestDebugInfo;
100
+ error?: string;
101
+ status: number;
102
+ }, feature: AiQuotaFeature): void;
79
103
  protected collectDomainSetupInputs(yamlConfig: MailmodoYaml, skipPrompts: boolean): Promise<{
80
104
  address: string;
81
105
  domain: string;
@@ -144,12 +168,15 @@ export declare abstract class BaseCommand extends Command {
144
168
  value: string;
145
169
  }>, guideUrl: string | undefined, json: boolean): void;
146
170
  /**
147
- * Builds the terminal error string for a failed API call, appending request
148
- * metadata when {@link ApiRequestDebugInfo} is available.
171
+ * Builds the terminal error string for a failed API call. In production only
172
+ * the user-facing message is returned; verbose request diagnostics (URL,
173
+ * status, response body, error code) are appended only when [[IS_DEV_MODE]]
174
+ * is true so end users never see internal request metadata.
149
175
  *
150
176
  * @param {string} message - Primary error text (HTTP message or generic).
151
177
  * @param {{ status: number; debug?: ApiRequestDebugInfo }} response - Failed API response.
152
- * @returns {string} Message plus indented Request details for troubleshooting.
178
+ * @returns {string} Message alone in production, or message plus indented
179
+ * Request details when running in dev/debug mode.
153
180
  */
154
181
  private formatApiFailure;
155
182
  }
@@ -4,8 +4,8 @@ import chalk from 'chalk';
4
4
  import ora from 'ora';
5
5
  import { ApiClient } from './api-client.js';
6
6
  import { loadConfig } from './config.js';
7
- import { API_ENDPOINTS } from './constants.js';
8
- import { ERRORS, INFO, PROMPTS, recordLabel, VALIDATION } from './messages.js';
7
+ import { API_ENDPOINTS, IS_DEV_MODE } from './constants.js';
8
+ import { ERRORS, INFO, PROMPTS, quotaExhaustedMessage, recordLabel, VALIDATION, } from './messages.js';
9
9
  import { loadYaml, saveYaml } from './yaml-config.js';
10
10
  export const FREE_TIER = 'free';
11
11
  /**
@@ -107,6 +107,35 @@ export class BaseCommand extends Command {
107
107
  }
108
108
  this.error(this.formatApiFailure(response.error || ERRORS.UNEXPECTED_API, response));
109
109
  }
110
+ /**
111
+ * Intercepts a 429 "Monthly quota exhausted" response from an AI rate-limited
112
+ * endpoint (analyze for `init`, email edit for `edit`) and exits with a
113
+ * feature-specific message that includes the server's error text, the reset
114
+ * date, optional retry hint, and the next-best action. Returns without
115
+ * exiting when the response is not a 429 so callers can fall through to
116
+ * `handleApiError` for non-quota failures.
117
+ *
118
+ * @param {{ status: number; error?: string; data?: unknown; debug?: ApiRequestDebugInfo }} response - The failed API response.
119
+ * Must carry the parsed JSON body (`data`) so reset/retry fields can be surfaced.
120
+ * @param {AiQuotaFeature} feature - Which AI quota was hit. Drives the default headline + tip
121
+ * shown when the server omits an explicit `error` string.
122
+ */
123
+ handleAiQuotaError(response, feature) {
124
+ if (response.status !== 429)
125
+ return;
126
+ const body = (response.data ?? {});
127
+ const isInit = feature === 'init';
128
+ const message = quotaExhaustedMessage({
129
+ defaultMessage: isInit
130
+ ? ERRORS.QUOTA_INIT_DEFAULT
131
+ : ERRORS.QUOTA_EDIT_DEFAULT,
132
+ limitReset: body.limitReset,
133
+ retryAfter: body.retryAfter,
134
+ serverError: typeof body.error === 'string' ? body.error : undefined,
135
+ tip: isInit ? ERRORS.QUOTA_INIT_TIP : ERRORS.QUOTA_EDIT_TIP,
136
+ });
137
+ this.error(this.formatApiFailure(message, response));
138
+ }
110
139
  async collectDomainSetupInputs(yamlConfig, skipPrompts) {
111
140
  if (skipPrompts) {
112
141
  const domain = yamlConfig.project?.domain || '';
@@ -267,16 +296,19 @@ export class BaseCommand extends Command {
267
296
  this.log(` Full guide: ${chalk.cyan(guideUrl)}\n`);
268
297
  }
269
298
  /**
270
- * Builds the terminal error string for a failed API call, appending request
271
- * metadata when {@link ApiRequestDebugInfo} is available.
299
+ * Builds the terminal error string for a failed API call. In production only
300
+ * the user-facing message is returned; verbose request diagnostics (URL,
301
+ * status, response body, error code) are appended only when [[IS_DEV_MODE]]
302
+ * is true so end users never see internal request metadata.
272
303
  *
273
304
  * @param {string} message - Primary error text (HTTP message or generic).
274
305
  * @param {{ status: number; debug?: ApiRequestDebugInfo }} response - Failed API response.
275
- * @returns {string} Message plus indented Request details for troubleshooting.
306
+ * @returns {string} Message alone in production, or message plus indented
307
+ * Request details when running in dev/debug mode.
276
308
  */
277
309
  formatApiFailure(message, response) {
278
310
  const { debug, status } = response;
279
- if (!debug) {
311
+ if (!IS_DEV_MODE || !debug) {
280
312
  return message;
281
313
  }
282
314
  return [
@@ -1,3 +1,9 @@
1
+ /**
2
+ * True when the CLI is running in a dev/debug context. Verbose request
3
+ * diagnostics (URL, status, response body, error code) are only surfaced
4
+ * when this is true so end users never see internal request metadata.
5
+ */
6
+ export declare const IS_DEV_MODE: boolean;
1
7
  export declare const API_BASE_URL = "https://app-vertex-debug.azurewebsites.net";
2
8
  export declare const API_ENDPOINTS: Readonly<{
3
9
  ANALYTICS: "/analytics";
@@ -1,5 +1,11 @@
1
1
  /** Set by `bin/dev.js` when running the CLI locally (tsx bootstrap). */
2
2
  const DEV_API_BASE_URL = 'https://app-vertex-debug.azurewebsites.net';
3
+ /**
4
+ * True when the CLI is running in a dev/debug context. Verbose request
5
+ * diagnostics (URL, status, response body, error code) are only surfaced
6
+ * when this is true so end users never see internal request metadata.
7
+ */
8
+ export const IS_DEV_MODE = Boolean(process.env.MAILMODO_DEV_TSX || process.env.MAILMODO_DEBUG);
3
9
  // const PRODUCTION_API_BASE_URL = 'https://api.mailmodo.com';
4
10
  const PRODUCTION_API_BASE_URL = 'https://app-vertex-debug.azurewebsites.net';
5
11
  export const API_BASE_URL = process.env.MAILMODO_DEV_TSX
@@ -9,6 +9,7 @@ export declare const PROMPTS: {
9
9
  readonly DOMAIN: "What domain will you send from?";
10
10
  readonly ENTER_AFTER_RECORDS: "Press Enter once you've added the records, or 'skip'.";
11
11
  readonly FROM_NAME: "Display name (optional, shown as sender name):";
12
+ readonly PAUSE_CONFIRM: "Pause this sequence? All scheduled and triggered sends will stop until you resume.";
12
13
  readonly REPLY_TO: "Reply-to address (optional, press Enter to use sender email):";
13
14
  readonly SENDER_EMAIL: "Sender email address:";
14
15
  };
@@ -19,6 +20,10 @@ export declare const ERRORS: {
19
20
  readonly INVALID_API_KEY: `Invalid API key. Run ${string} to re-authenticate.`;
20
21
  readonly NOT_LOGGED_IN: `Not logged in. Run ${string} to authenticate.`;
21
22
  readonly NO_YAML: `No mailmodo.yaml found. Run ${string} first.`;
23
+ readonly QUOTA_EDIT_DEFAULT: "Edit limit reached for this month.";
24
+ readonly QUOTA_EDIT_TIP: "AI edits are limited to 50 per account per month. Manually edit the template file under ./mailmodo to make further changes.";
25
+ readonly QUOTA_INIT_DEFAULT: "Regeneration limit reached.";
26
+ readonly QUOTA_INIT_TIP: `Regenerations are limited to 5 per account per month. Run ${string} to modify individual emails instead.`;
22
27
  readonly RATE_LIMIT: "Rate limit exceeded. Please try again later.";
23
28
  readonly UNEXPECTED_API: "An unexpected API error occurred.";
24
29
  };
@@ -33,7 +38,35 @@ export declare const INFO: {
33
38
  Then: ${string}`;
34
39
  readonly DOMAIN_PENDING_VERIFICATION: `Your domain is not verified yet. Please verify it first. Run ${string} to check the status.`;
35
40
  readonly FREE_TIER_CAP_BLOCKED: `Monthly cap is a paid-tier setting and is not available on the free tier. Run ${string} to add a payment method, then set a cap.`;
41
+ readonly PAUSE_CANCELLED: "Pause cancelled. Sequence is still live.";
36
42
  readonly SEQUENCES_NOT_DEPLOYED: `Sequences saved but ${string}.`;
37
43
  };
44
+ export declare function pauseSuccess(sequenceId: string): string;
45
+ export declare function pauseAlready(sequenceId: string): string;
46
+ export declare function resumeSuccess(sequenceId: string): string;
47
+ export declare function resumeAlready(sequenceId: string): string;
38
48
  export declare function yamlParseError(detail: string): string;
39
49
  export declare function recordLabel(index: number): string;
50
+ /**
51
+ * Parses an ISO-8601 timestamp into a `YYYY-MM-DD` date string.
52
+ * Returns the raw input if parsing fails so the original value is preserved.
53
+ */
54
+ export declare function formatQuotaResetDate(value?: string): string | undefined;
55
+ /**
56
+ * Formats a `retry-after` value (seconds) into a short, human-readable hint
57
+ * suitable for appending to a 429 error message.
58
+ */
59
+ export declare function formatRetryAfter(seconds?: number): string | undefined;
60
+ /**
61
+ * Builds the multi-line message printed when an AI quota (init regeneration or
62
+ * edit) is exhausted. `serverError` is the message returned by the API; the
63
+ * default text is used when the server omits it. The tip lists the next action
64
+ * the user can take (e.g. switch from init to edit).
65
+ */
66
+ export declare function quotaExhaustedMessage(input: {
67
+ defaultMessage: string;
68
+ limitReset?: string;
69
+ retryAfter?: number;
70
+ serverError?: string;
71
+ tip: string;
72
+ }): string;
@@ -10,6 +10,7 @@ export const PROMPTS = {
10
10
  DOMAIN: 'What domain will you send from?',
11
11
  ENTER_AFTER_RECORDS: "Press Enter once you've added the records, or 'skip'.",
12
12
  FROM_NAME: 'Display name (optional, shown as sender name):',
13
+ PAUSE_CONFIRM: 'Pause this sequence? All scheduled and triggered sends will stop until you resume.',
13
14
  REPLY_TO: 'Reply-to address (optional, press Enter to use sender email):',
14
15
  SENDER_EMAIL: 'Sender email address:',
15
16
  };
@@ -20,6 +21,10 @@ export const ERRORS = {
20
21
  INVALID_API_KEY: `Invalid API key. Run ${chalk.cyan('mailmodo login')} to re-authenticate.`,
21
22
  NOT_LOGGED_IN: `Not logged in. Run ${chalk.cyan('mailmodo login')} to authenticate.`,
22
23
  NO_YAML: `No mailmodo.yaml found. Run ${chalk.cyan('mailmodo init')} first.`,
24
+ QUOTA_EDIT_DEFAULT: 'Edit limit reached for this month.',
25
+ QUOTA_EDIT_TIP: 'AI edits are limited to 50 per account per month. Manually edit the template file under ./mailmodo to make further changes.',
26
+ QUOTA_INIT_DEFAULT: 'Regeneration limit reached.',
27
+ QUOTA_INIT_TIP: `Regenerations are limited to 5 per account per month. Run ${chalk.cyan('mailmodo edit <id>')} to modify individual emails instead.`,
23
28
  RATE_LIMIT: 'Rate limit exceeded. Please try again later.',
24
29
  UNEXPECTED_API: 'An unexpected API error occurred.',
25
30
  };
@@ -33,8 +38,21 @@ export const INFO = {
33
38
  DOMAIN_NOT_DEPLOYED_HINT: `When ready, run: ${chalk.cyan('mailmodo domain')}\n Then: ${chalk.cyan('mailmodo deploy')}`,
34
39
  DOMAIN_PENDING_VERIFICATION: `Your domain is not verified yet. Please verify it first. Run ${chalk.cyan('mailmodo domain --verify')} to check the status.`,
35
40
  FREE_TIER_CAP_BLOCKED: `Monthly cap is a paid-tier setting and is not available on the free tier. Run ${chalk.cyan("'mailmodo billing --checkout'")} to add a payment method, then set a cap.`,
41
+ PAUSE_CANCELLED: 'Pause cancelled. Sequence is still live.',
36
42
  SEQUENCES_NOT_DEPLOYED: `Sequences saved but ${chalk.yellow('NOT deployed')}.`,
37
43
  };
44
+ export function pauseSuccess(sequenceId) {
45
+ return `Sequence ${chalk.cyan(sequenceId)} paused. Run ${chalk.cyan(`mailmodo deploy --resume ${sequenceId}`)} to resume.`;
46
+ }
47
+ export function pauseAlready(sequenceId) {
48
+ return `Sequence ${chalk.cyan(sequenceId)} is already paused. No change made.`;
49
+ }
50
+ export function resumeSuccess(sequenceId) {
51
+ return `Sequence ${chalk.cyan(sequenceId)} resumed. Scheduled and triggered sends are live again.`;
52
+ }
53
+ export function resumeAlready(sequenceId) {
54
+ return `Sequence ${chalk.cyan(sequenceId)} is already active. No change made.`;
55
+ }
38
56
  export function yamlParseError(detail) {
39
57
  return `mailmodo.yaml has invalid YAML syntax:\n${detail}`;
40
58
  }
@@ -42,3 +60,56 @@ export function recordLabel(index) {
42
60
  const labels = ['DKIM', 'DMARC', 'Return Path'];
43
61
  return labels[index] || `Record ${index + 1}`;
44
62
  }
63
+ /**
64
+ * Parses an ISO-8601 timestamp into a `YYYY-MM-DD` date string.
65
+ * Returns the raw input if parsing fails so the original value is preserved.
66
+ */
67
+ export function formatQuotaResetDate(value) {
68
+ if (!value)
69
+ return undefined;
70
+ const parsed = new Date(value);
71
+ if (Number.isNaN(parsed.getTime()))
72
+ return value;
73
+ const year = parsed.getUTCFullYear();
74
+ const month = String(parsed.getUTCMonth() + 1).padStart(2, '0');
75
+ const day = String(parsed.getUTCDate()).padStart(2, '0');
76
+ return `${year}-${month}-${day}`;
77
+ }
78
+ /**
79
+ * Formats a `retry-after` value (seconds) into a short, human-readable hint
80
+ * suitable for appending to a 429 error message.
81
+ */
82
+ export function formatRetryAfter(seconds) {
83
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0)
84
+ return undefined;
85
+ if (seconds < 60)
86
+ return `${Math.round(seconds)} seconds`;
87
+ const minutes = Math.round(seconds / 60);
88
+ if (minutes < 60)
89
+ return `${minutes} minutes`;
90
+ const hours = Math.round(seconds / 3600);
91
+ if (hours < 24)
92
+ return `${hours} hours`;
93
+ const days = Math.round(seconds / 86_400);
94
+ return `${days} days`;
95
+ }
96
+ /**
97
+ * Builds the multi-line message printed when an AI quota (init regeneration or
98
+ * edit) is exhausted. `serverError` is the message returned by the API; the
99
+ * default text is used when the server omits it. The tip lists the next action
100
+ * the user can take (e.g. switch from init to edit).
101
+ */
102
+ export function quotaExhaustedMessage(input) {
103
+ const headline = input.serverError || input.defaultMessage;
104
+ const lines = [headline];
105
+ const resetDate = formatQuotaResetDate(input.limitReset);
106
+ if (resetDate && !headline.includes(resetDate)) {
107
+ lines.push(`Resets on ${resetDate}.`);
108
+ }
109
+ const retryHint = formatRetryAfter(input.retryAfter);
110
+ if (retryHint) {
111
+ lines.push(`Try again in ${retryHint}.`);
112
+ }
113
+ lines.push('', input.tip);
114
+ return lines.join('\n');
115
+ }
@@ -140,10 +140,12 @@
140
140
  "deploy": {
141
141
  "aliases": [],
142
142
  "args": {},
143
- "description": "Deploy email sequences and verify sending domain",
143
+ "description": "Deploy, pause, or resume an email sequence",
144
144
  "examples": [
145
145
  "<%= config.bin %> deploy",
146
- "<%= config.bin %> deploy --yes"
146
+ "<%= config.bin %> deploy --yes",
147
+ "<%= config.bin %> deploy --pause seq_abc123",
148
+ "<%= config.bin %> deploy --resume seq_abc123 --json"
147
149
  ],
148
150
  "flags": {
149
151
  "json": {
@@ -158,6 +160,26 @@
158
160
  "name": "yes",
159
161
  "allowNo": false,
160
162
  "type": "boolean"
163
+ },
164
+ "pause": {
165
+ "description": "Pause a deployed sequence by ID (stops scheduled + triggered sends)",
166
+ "exclusive": [
167
+ "resume"
168
+ ],
169
+ "name": "pause",
170
+ "hasDynamicHelp": false,
171
+ "multiple": false,
172
+ "type": "option"
173
+ },
174
+ "resume": {
175
+ "description": "Resume a paused sequence by ID",
176
+ "exclusive": [
177
+ "pause"
178
+ ],
179
+ "name": "resume",
180
+ "hasDynamicHelp": false,
181
+ "multiple": false,
182
+ "type": "option"
161
183
  }
162
184
  },
163
185
  "hasDynamicHelp": false,
@@ -176,6 +198,45 @@
176
198
  "index.js"
177
199
  ]
178
200
  },
201
+ "deployments": {
202
+ "aliases": [],
203
+ "args": {},
204
+ "description": "List every deployed sequence on this account, with the IDs needed for deploy --pause / --resume",
205
+ "examples": [
206
+ "<%= config.bin %> deployments",
207
+ "<%= config.bin %> deployments --json"
208
+ ],
209
+ "flags": {
210
+ "json": {
211
+ "description": "Output as JSON",
212
+ "name": "json",
213
+ "allowNo": false,
214
+ "type": "boolean"
215
+ },
216
+ "yes": {
217
+ "char": "y",
218
+ "description": "Skip confirmation prompts",
219
+ "name": "yes",
220
+ "allowNo": false,
221
+ "type": "boolean"
222
+ }
223
+ },
224
+ "hasDynamicHelp": false,
225
+ "hiddenAliases": [],
226
+ "id": "deployments",
227
+ "pluginAlias": "@mailmodo/cli",
228
+ "pluginName": "@mailmodo/cli",
229
+ "pluginType": "core",
230
+ "strict": true,
231
+ "enableJsonFlag": false,
232
+ "isESM": true,
233
+ "relativePath": [
234
+ "dist",
235
+ "commands",
236
+ "deployments",
237
+ "index.js"
238
+ ]
239
+ },
179
240
  "domain": {
180
241
  "aliases": [],
181
242
  "args": {},
@@ -365,12 +426,13 @@
365
426
  "index.js"
366
427
  ]
367
428
  },
368
- "logout": {
429
+ "login": {
369
430
  "aliases": [],
370
431
  "args": {},
371
- "description": "Sign out by removing saved credentials from this machine",
432
+ "description": "Authenticate with Mailmodo using your API key",
372
433
  "examples": [
373
- "<%= config.bin %> logout"
434
+ "<%= config.bin %> login",
435
+ "MAILMODO_API_KEY=YOUR_API_KEY <%= config.bin %> login"
374
436
  ],
375
437
  "flags": {
376
438
  "json": {
@@ -389,7 +451,7 @@
389
451
  },
390
452
  "hasDynamicHelp": false,
391
453
  "hiddenAliases": [],
392
- "id": "logout",
454
+ "id": "login",
393
455
  "pluginAlias": "@mailmodo/cli",
394
456
  "pluginName": "@mailmodo/cli",
395
457
  "pluginType": "core",
@@ -399,17 +461,16 @@
399
461
  "relativePath": [
400
462
  "dist",
401
463
  "commands",
402
- "logout",
464
+ "login",
403
465
  "index.js"
404
466
  ]
405
467
  },
406
- "login": {
468
+ "logout": {
407
469
  "aliases": [],
408
470
  "args": {},
409
- "description": "Authenticate with Mailmodo using your API key",
471
+ "description": "Sign out by removing saved credentials from this machine",
410
472
  "examples": [
411
- "<%= config.bin %> login",
412
- "MAILMODO_API_KEY=YOUR_API_KEY <%= config.bin %> login"
473
+ "<%= config.bin %> logout"
413
474
  ],
414
475
  "flags": {
415
476
  "json": {
@@ -428,7 +489,7 @@
428
489
  },
429
490
  "hasDynamicHelp": false,
430
491
  "hiddenAliases": [],
431
- "id": "login",
492
+ "id": "logout",
432
493
  "pluginAlias": "@mailmodo/cli",
433
494
  "pluginName": "@mailmodo/cli",
434
495
  "pluginType": "core",
@@ -438,23 +499,19 @@
438
499
  "relativePath": [
439
500
  "dist",
440
501
  "commands",
441
- "login",
502
+ "logout",
442
503
  "index.js"
443
504
  ]
444
505
  },
445
- "preview": {
506
+ "logs": {
446
507
  "aliases": [],
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",
508
+ "args": {},
509
+ "description": "View email send logs and delivery events",
454
510
  "examples": [
455
- "<%= config.bin %> preview welcome",
456
- "<%= config.bin %> preview welcome --text",
457
- "<%= config.bin %> preview welcome --send me@example.com"
511
+ "<%= config.bin %> logs",
512
+ "<%= config.bin %> logs --email sarah@example.com",
513
+ "<%= config.bin %> logs --failed",
514
+ "<%= config.bin %> logs --json"
458
515
  ],
459
516
  "flags": {
460
517
  "json": {
@@ -470,23 +527,39 @@
470
527
  "allowNo": false,
471
528
  "type": "boolean"
472
529
  },
473
- "send": {
474
- "description": "Send test email to this address",
475
- "name": "send",
530
+ "email": {
531
+ "description": "Filter logs by contact email",
532
+ "name": "email",
476
533
  "hasDynamicHelp": false,
477
534
  "multiple": false,
478
535
  "type": "option"
479
536
  },
480
- "text": {
481
- "description": "Output plain text version (for AI agents)",
482
- "name": "text",
537
+ "failed": {
538
+ "description": "Show only failed/bounced events",
539
+ "name": "failed",
483
540
  "allowNo": false,
484
541
  "type": "boolean"
542
+ },
543
+ "limit": {
544
+ "description": "Entries per page (max 200)",
545
+ "name": "limit",
546
+ "default": 50,
547
+ "hasDynamicHelp": false,
548
+ "multiple": false,
549
+ "type": "option"
550
+ },
551
+ "page": {
552
+ "description": "Page number",
553
+ "name": "page",
554
+ "default": 1,
555
+ "hasDynamicHelp": false,
556
+ "multiple": false,
557
+ "type": "option"
485
558
  }
486
559
  },
487
560
  "hasDynamicHelp": false,
488
561
  "hiddenAliases": [],
489
- "id": "preview",
562
+ "id": "logs",
490
563
  "pluginAlias": "@mailmodo/cli",
491
564
  "pluginName": "@mailmodo/cli",
492
565
  "pluginType": "core",
@@ -496,19 +569,23 @@
496
569
  "relativePath": [
497
570
  "dist",
498
571
  "commands",
499
- "preview",
572
+ "logs",
500
573
  "index.js"
501
574
  ]
502
575
  },
503
- "logs": {
576
+ "preview": {
504
577
  "aliases": [],
505
- "args": {},
506
- "description": "View email send logs and delivery events",
578
+ "args": {
579
+ "id": {
580
+ "description": "Email template ID to preview",
581
+ "name": "id"
582
+ }
583
+ },
584
+ "description": "Preview an email in browser, as text, or send a test",
507
585
  "examples": [
508
- "<%= config.bin %> logs",
509
- "<%= config.bin %> logs --email sarah@example.com",
510
- "<%= config.bin %> logs --failed",
511
- "<%= config.bin %> logs --json"
586
+ "<%= config.bin %> preview welcome",
587
+ "<%= config.bin %> preview welcome --text",
588
+ "<%= config.bin %> preview welcome --send me@example.com"
512
589
  ],
513
590
  "flags": {
514
591
  "json": {
@@ -524,39 +601,23 @@
524
601
  "allowNo": false,
525
602
  "type": "boolean"
526
603
  },
527
- "email": {
528
- "description": "Filter logs by contact email",
529
- "name": "email",
604
+ "send": {
605
+ "description": "Send test email to this address",
606
+ "name": "send",
530
607
  "hasDynamicHelp": false,
531
608
  "multiple": false,
532
609
  "type": "option"
533
610
  },
534
- "failed": {
535
- "description": "Show only failed/bounced events",
536
- "name": "failed",
611
+ "text": {
612
+ "description": "Output plain text version (for AI agents)",
613
+ "name": "text",
537
614
  "allowNo": false,
538
615
  "type": "boolean"
539
- },
540
- "limit": {
541
- "description": "Entries per page (max 200)",
542
- "name": "limit",
543
- "default": 50,
544
- "hasDynamicHelp": false,
545
- "multiple": false,
546
- "type": "option"
547
- },
548
- "page": {
549
- "description": "Page number",
550
- "name": "page",
551
- "default": 1,
552
- "hasDynamicHelp": false,
553
- "multiple": false,
554
- "type": "option"
555
616
  }
556
617
  },
557
618
  "hasDynamicHelp": false,
558
619
  "hiddenAliases": [],
559
- "id": "logs",
620
+ "id": "preview",
560
621
  "pluginAlias": "@mailmodo/cli",
561
622
  "pluginName": "@mailmodo/cli",
562
623
  "pluginType": "core",
@@ -566,7 +627,7 @@
566
627
  "relativePath": [
567
628
  "dist",
568
629
  "commands",
569
- "logs",
630
+ "preview",
570
631
  "index.js"
571
632
  ]
572
633
  },
@@ -657,5 +718,5 @@
657
718
  ]
658
719
  }
659
720
  },
660
- "version": "0.0.49"
721
+ "version": "0.0.50-beta.pr52.80"
661
722
  }
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.49",
4
+ "version": "0.0.50-beta.pr52.80",
5
5
  "author": "provishalk",
6
6
  "bin": {
7
7
  "mailmodo": "bin/run.js"