@mailmodo/cli 0.0.48-beta.pr50.76 → 0.0.49-beta.pr51.77

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.
@@ -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;
@@ -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;
@@ -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;
@@ -5,7 +5,7 @@ import ora from 'ora';
5
5
  import { ApiClient } from './api-client.js';
6
6
  import { loadConfig } from './config.js';
7
7
  import { API_ENDPOINTS } from './constants.js';
8
- import { ERRORS, INFO, PROMPTS, recordLabel, VALIDATION } from './messages.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 || '';
@@ -19,6 +19,10 @@ export declare const ERRORS: {
19
19
  readonly INVALID_API_KEY: `Invalid API key. Run ${string} to re-authenticate.`;
20
20
  readonly NOT_LOGGED_IN: `Not logged in. Run ${string} to authenticate.`;
21
21
  readonly NO_YAML: `No mailmodo.yaml found. Run ${string} first.`;
22
+ readonly QUOTA_EDIT_DEFAULT: "Edit limit reached for this month.";
23
+ 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.";
24
+ readonly QUOTA_INIT_DEFAULT: "Regeneration limit reached.";
25
+ readonly QUOTA_INIT_TIP: `Regenerations are limited to 5 per account per month. Run ${string} to modify individual emails instead.`;
22
26
  readonly RATE_LIMIT: "Rate limit exceeded. Please try again later.";
23
27
  readonly UNEXPECTED_API: "An unexpected API error occurred.";
24
28
  };
@@ -37,3 +41,26 @@ export declare const INFO: {
37
41
  };
38
42
  export declare function yamlParseError(detail: string): string;
39
43
  export declare function recordLabel(index: number): string;
44
+ /**
45
+ * Parses an ISO-8601 timestamp into a `YYYY-MM-DD` date string.
46
+ * Returns the raw input if parsing fails so the original value is preserved.
47
+ */
48
+ export declare function formatQuotaResetDate(value?: string): string | undefined;
49
+ /**
50
+ * Formats a `retry-after` value (seconds) into a short, human-readable hint
51
+ * suitable for appending to a 429 error message.
52
+ */
53
+ export declare function formatRetryAfter(seconds?: number): string | undefined;
54
+ /**
55
+ * Builds the multi-line message printed when an AI quota (init regeneration or
56
+ * edit) is exhausted. `serverError` is the message returned by the API; the
57
+ * default text is used when the server omits it. The tip lists the next action
58
+ * the user can take (e.g. switch from init to edit).
59
+ */
60
+ export declare function quotaExhaustedMessage(input: {
61
+ defaultMessage: string;
62
+ limitReset?: string;
63
+ retryAfter?: number;
64
+ serverError?: string;
65
+ tip: string;
66
+ }): string;
@@ -20,6 +20,10 @@ export const ERRORS = {
20
20
  INVALID_API_KEY: `Invalid API key. Run ${chalk.cyan('mailmodo login')} to re-authenticate.`,
21
21
  NOT_LOGGED_IN: `Not logged in. Run ${chalk.cyan('mailmodo login')} to authenticate.`,
22
22
  NO_YAML: `No mailmodo.yaml found. Run ${chalk.cyan('mailmodo init')} first.`,
23
+ QUOTA_EDIT_DEFAULT: 'Edit limit reached for this month.',
24
+ 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
+ QUOTA_INIT_DEFAULT: 'Regeneration limit reached.',
26
+ QUOTA_INIT_TIP: `Regenerations are limited to 5 per account per month. Run ${chalk.cyan('mailmodo edit <id>')} to modify individual emails instead.`,
23
27
  RATE_LIMIT: 'Rate limit exceeded. Please try again later.',
24
28
  UNEXPECTED_API: 'An unexpected API error occurred.',
25
29
  };
@@ -42,3 +46,56 @@ export function recordLabel(index) {
42
46
  const labels = ['DKIM', 'DMARC', 'Return Path'];
43
47
  return labels[index] || `Record ${index + 1}`;
44
48
  }
49
+ /**
50
+ * Parses an ISO-8601 timestamp into a `YYYY-MM-DD` date string.
51
+ * Returns the raw input if parsing fails so the original value is preserved.
52
+ */
53
+ export function formatQuotaResetDate(value) {
54
+ if (!value)
55
+ return undefined;
56
+ const parsed = new Date(value);
57
+ if (Number.isNaN(parsed.getTime()))
58
+ return value;
59
+ const year = parsed.getUTCFullYear();
60
+ const month = String(parsed.getUTCMonth() + 1).padStart(2, '0');
61
+ const day = String(parsed.getUTCDate()).padStart(2, '0');
62
+ return `${year}-${month}-${day}`;
63
+ }
64
+ /**
65
+ * Formats a `retry-after` value (seconds) into a short, human-readable hint
66
+ * suitable for appending to a 429 error message.
67
+ */
68
+ export function formatRetryAfter(seconds) {
69
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0)
70
+ return undefined;
71
+ if (seconds < 60)
72
+ return `${Math.round(seconds)} seconds`;
73
+ const minutes = Math.round(seconds / 60);
74
+ if (minutes < 60)
75
+ return `${minutes} minutes`;
76
+ const hours = Math.round(seconds / 3600);
77
+ if (hours < 24)
78
+ return `${hours} hours`;
79
+ const days = Math.round(seconds / 86_400);
80
+ return `${days} days`;
81
+ }
82
+ /**
83
+ * Builds the multi-line message printed when an AI quota (init regeneration or
84
+ * edit) is exhausted. `serverError` is the message returned by the API; the
85
+ * default text is used when the server omits it. The tip lists the next action
86
+ * the user can take (e.g. switch from init to edit).
87
+ */
88
+ export function quotaExhaustedMessage(input) {
89
+ const headline = input.serverError || input.defaultMessage;
90
+ const lines = [headline];
91
+ const resetDate = formatQuotaResetDate(input.limitReset);
92
+ if (resetDate && !headline.includes(resetDate)) {
93
+ lines.push(`Resets on ${resetDate}.`);
94
+ }
95
+ const retryHint = formatRetryAfter(input.retryAfter);
96
+ if (retryHint) {
97
+ lines.push(`Try again in ${retryHint}.`);
98
+ }
99
+ lines.push('', input.tip);
100
+ return lines.join('\n');
101
+ }
@@ -442,15 +442,19 @@
442
442
  "index.js"
443
443
  ]
444
444
  },
445
- "logs": {
445
+ "preview": {
446
446
  "aliases": [],
447
- "args": {},
448
- "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",
449
454
  "examples": [
450
- "<%= config.bin %> logs",
451
- "<%= config.bin %> logs --email sarah@example.com",
452
- "<%= config.bin %> logs --failed",
453
- "<%= config.bin %> logs --json"
455
+ "<%= config.bin %> preview welcome",
456
+ "<%= config.bin %> preview welcome --text",
457
+ "<%= config.bin %> preview welcome --send me@example.com"
454
458
  ],
455
459
  "flags": {
456
460
  "json": {
@@ -466,39 +470,23 @@
466
470
  "allowNo": false,
467
471
  "type": "boolean"
468
472
  },
469
- "email": {
470
- "description": "Filter logs by contact email",
471
- "name": "email",
473
+ "send": {
474
+ "description": "Send test email to this address",
475
+ "name": "send",
472
476
  "hasDynamicHelp": false,
473
477
  "multiple": false,
474
478
  "type": "option"
475
479
  },
476
- "failed": {
477
- "description": "Show only failed/bounced events",
478
- "name": "failed",
480
+ "text": {
481
+ "description": "Output plain text version (for AI agents)",
482
+ "name": "text",
479
483
  "allowNo": false,
480
484
  "type": "boolean"
481
- },
482
- "limit": {
483
- "description": "Entries per page (max 200)",
484
- "name": "limit",
485
- "default": 50,
486
- "hasDynamicHelp": false,
487
- "multiple": false,
488
- "type": "option"
489
- },
490
- "page": {
491
- "description": "Page number",
492
- "name": "page",
493
- "default": 1,
494
- "hasDynamicHelp": false,
495
- "multiple": false,
496
- "type": "option"
497
485
  }
498
486
  },
499
487
  "hasDynamicHelp": false,
500
488
  "hiddenAliases": [],
501
- "id": "logs",
489
+ "id": "preview",
502
490
  "pluginAlias": "@mailmodo/cli",
503
491
  "pluginName": "@mailmodo/cli",
504
492
  "pluginType": "core",
@@ -508,23 +496,19 @@
508
496
  "relativePath": [
509
497
  "dist",
510
498
  "commands",
511
- "logs",
499
+ "preview",
512
500
  "index.js"
513
501
  ]
514
502
  },
515
- "preview": {
503
+ "logs": {
516
504
  "aliases": [],
517
- "args": {
518
- "id": {
519
- "description": "Email template ID to preview",
520
- "name": "id"
521
- }
522
- },
523
- "description": "Preview an email in browser, as text, or send a test",
505
+ "args": {},
506
+ "description": "View email send logs and delivery events",
524
507
  "examples": [
525
- "<%= config.bin %> preview welcome",
526
- "<%= config.bin %> preview welcome --text",
527
- "<%= config.bin %> preview welcome --send me@example.com"
508
+ "<%= config.bin %> logs",
509
+ "<%= config.bin %> logs --email sarah@example.com",
510
+ "<%= config.bin %> logs --failed",
511
+ "<%= config.bin %> logs --json"
528
512
  ],
529
513
  "flags": {
530
514
  "json": {
@@ -540,23 +524,39 @@
540
524
  "allowNo": false,
541
525
  "type": "boolean"
542
526
  },
543
- "send": {
544
- "description": "Send test email to this address",
545
- "name": "send",
527
+ "email": {
528
+ "description": "Filter logs by contact email",
529
+ "name": "email",
546
530
  "hasDynamicHelp": false,
547
531
  "multiple": false,
548
532
  "type": "option"
549
533
  },
550
- "text": {
551
- "description": "Output plain text version (for AI agents)",
552
- "name": "text",
534
+ "failed": {
535
+ "description": "Show only failed/bounced events",
536
+ "name": "failed",
553
537
  "allowNo": false,
554
538
  "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
555
  }
556
556
  },
557
557
  "hasDynamicHelp": false,
558
558
  "hiddenAliases": [],
559
- "id": "preview",
559
+ "id": "logs",
560
560
  "pluginAlias": "@mailmodo/cli",
561
561
  "pluginName": "@mailmodo/cli",
562
562
  "pluginType": "core",
@@ -566,7 +566,7 @@
566
566
  "relativePath": [
567
567
  "dist",
568
568
  "commands",
569
- "preview",
569
+ "logs",
570
570
  "index.js"
571
571
  ]
572
572
  },
@@ -657,5 +657,5 @@
657
657
  ]
658
658
  }
659
659
  },
660
- "version": "0.0.48-beta.pr50.76"
660
+ "version": "0.0.49-beta.pr51.77"
661
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.48-beta.pr50.76",
4
+ "version": "0.0.49-beta.pr51.77",
5
5
  "author": "provishalk",
6
6
  "bin": {
7
7
  "mailmodo": "bin/run.js"