@mailmodo/cli 0.0.56-beta.pr58.99 → 0.0.57-beta.pr59.109

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.
@@ -1,7 +1,7 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import { BaseCommand } from '../../lib/base-command.js';
3
3
  import { API_ENDPOINTS } from '../../lib/constants.js';
4
- import { loadYaml, saveYaml } from '../../lib/yaml-config.js';
4
+ import { loadYaml, saveYaml, } from '../../lib/yaml-config.js';
5
5
  import { analyzeProduct, promptProductUrl, } from '../../lib/commands/init/analysis.js';
6
6
  import { confirmOverwrite, logInitSuccess, } from '../../lib/commands/init/output.js';
7
7
  import { applyMonthlyCap, buildEmailConfigs, buildYamlConfig, preserveUserFields, saveAllTemplates, } from '../../lib/commands/init/payload.js';
@@ -20,6 +20,13 @@ export default class Init extends BaseCommand {
20
20
  await this.ensureAuth();
21
21
  const ctx = this.makeCtx();
22
22
  const baseFlags = { json: flags.json, yes: flags.yes };
23
+ let serverYaml = null;
24
+ if (this.isBlankDirectory()) {
25
+ const result = await this.promptInitServerRestore(baseFlags);
26
+ if (result.restored)
27
+ return;
28
+ serverYaml = result.serverYaml;
29
+ }
23
30
  const existing = await loadYaml();
24
31
  if (!(await confirmOverwrite(ctx, baseFlags, existing)))
25
32
  return;
@@ -33,8 +40,9 @@ export default class Init extends BaseCommand {
33
40
  const generatedEmails = generateResponse.data?.emails || [];
34
41
  const emailConfigs = buildEmailConfigs(analysisPayload, generatedEmails);
35
42
  const yamlConfig = buildYamlConfig(analysisPayload, emailConfigs, productUrl);
36
- if (existing)
37
- preserveUserFields(yamlConfig, existing);
43
+ const fieldSource = existing ?? serverYaml;
44
+ if (fieldSource)
45
+ preserveUserFields(yamlConfig, fieldSource);
38
46
  await saveYaml(yamlConfig);
39
47
  await applyMonthlyCap(ctx, yamlConfig);
40
48
  await saveAllTemplates(analysisPayload.recommendedEmails, generatedEmails);
@@ -27,9 +27,7 @@ export default class Login extends BaseCommand {
27
27
  if (!envKey) {
28
28
  const existing = await loadConfig();
29
29
  if (existing?.apiKey) {
30
- const existingClient = new ApiClient(existing.apiKey);
31
- const yamlRestored = await this.recoverYamlAfterLogin(existingClient);
32
- logAlreadyLoggedIn(ctx, existing, yamlRestored, { json: flags.json });
30
+ logAlreadyLoggedIn(ctx, existing, { json: flags.json });
33
31
  return;
34
32
  }
35
33
  }
@@ -42,8 +40,7 @@ export default class Login extends BaseCommand {
42
40
  }
43
41
  const { email, totalFreeRemaining, paidEmailsRemaining, plan } = response.data;
44
42
  await saveConfig({ apiKey: trimmedKey, email, totalFreeRemaining });
45
- const yamlRestored = await this.recoverYamlAfterLogin(client);
46
- logLoginSuccess(ctx, { email, paidEmailsRemaining, plan, totalFreeRemaining }, yamlRestored, { json: flags.json });
43
+ logLoginSuccess(ctx, { email, paidEmailsRemaining, plan, totalFreeRemaining }, { json: flags.json });
47
44
  }
48
45
  makeCtx() {
49
46
  return {
@@ -0,0 +1,22 @@
1
+ import { BaseCommand } from '../../lib/base-command.js';
2
+ export default class Report extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ contact: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ 'email-id': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ event: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ from: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ 'group-by': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
12
+ output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
13
+ page: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
14
+ preset: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
+ sequence: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
+ to: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
+ };
20
+ run(): Promise<void>;
21
+ private makeCtx;
22
+ }
@@ -0,0 +1,123 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../../lib/base-command.js';
3
+ import { API_ENDPOINTS } from '../../lib/constants.js';
4
+ import { REPORTS } from '../../lib/messages.js';
5
+ import { buildReportPayload } from '../../lib/commands/report/payload.js';
6
+ import { renderReport } from '../../lib/commands/report/output.js';
7
+ import { promptReportFlags } from '../../lib/commands/report/prompt.js';
8
+ export default class Report extends BaseCommand {
9
+ static description = 'Fetch an email analytics report';
10
+ static examples = [
11
+ '<%= config.bin %> report --preset last7d',
12
+ '<%= config.bin %> report --preset last30d --group-by emailId',
13
+ '<%= config.bin %> report --from YYYY-MM-DD --to YYYY-MM-DD --output timeseries',
14
+ '<%= config.bin %> report --preset last7d --output entries --page 1',
15
+ '<%= config.bin %> report --preset last30d --sequence welcome-flow --event opened',
16
+ '<%= config.bin %> report --preset last7d --json',
17
+ ];
18
+ static flags = {
19
+ ...BaseCommand.baseFlags,
20
+ contact: Flags.string({
21
+ description: 'Filter by contact email (repeatable)',
22
+ multiple: true,
23
+ }),
24
+ 'email-id': Flags.string({
25
+ description: 'Filter by email template ID (repeatable)',
26
+ multiple: true,
27
+ }),
28
+ event: Flags.string({
29
+ description: 'Filter by event type (repeatable)',
30
+ multiple: true,
31
+ options: [
32
+ 'bounced',
33
+ 'clicked',
34
+ 'complained',
35
+ 'delivered',
36
+ 'opened',
37
+ 'sent',
38
+ 'skipped',
39
+ 'unsubscribed',
40
+ ],
41
+ }),
42
+ from: Flags.string({
43
+ description: 'Start of time range, inclusive (YYYY-MM-DD)',
44
+ exclusive: ['preset'],
45
+ }),
46
+ 'group-by': Flags.string({
47
+ default: 'none',
48
+ description: 'Group results by dimension',
49
+ options: [
50
+ 'contact',
51
+ 'day',
52
+ 'emailId',
53
+ 'hour',
54
+ 'none',
55
+ 'sequenceId',
56
+ 'status',
57
+ ],
58
+ }),
59
+ limit: Flags.integer({
60
+ default: 50,
61
+ description: 'Entries per page, max 200 (entries output only)',
62
+ max: 200,
63
+ }),
64
+ output: Flags.string({
65
+ default: 'summary',
66
+ description: 'Output shape: summary | entries | timeseries',
67
+ options: ['entries', 'summary', 'timeseries'],
68
+ }),
69
+ page: Flags.integer({
70
+ default: 1,
71
+ description: 'Page number (entries output only)',
72
+ }),
73
+ preset: Flags.string({
74
+ description: 'Relative time range preset',
75
+ exclusive: ['from', 'to'],
76
+ options: [
77
+ 'last30d',
78
+ 'last7d',
79
+ 'last90d',
80
+ 'lastMonth',
81
+ 'thisMonth',
82
+ 'today',
83
+ 'yesterday',
84
+ ],
85
+ }),
86
+ sequence: Flags.string({
87
+ description: 'Filter by sequence ID (repeatable)',
88
+ multiple: true,
89
+ }),
90
+ to: Flags.string({
91
+ description: 'End of time range, exclusive (YYYY-MM-DD)',
92
+ exclusive: ['preset'],
93
+ }),
94
+ };
95
+ async run() {
96
+ const { flags } = await this.parse(Report);
97
+ await this.ensureAuth();
98
+ if ((flags.from && !flags.to) || (!flags.from && flags.to)) {
99
+ this.error(REPORTS.FROM_TO_REQUIRED);
100
+ }
101
+ const resolved = flags.json
102
+ ? flags
103
+ : await promptReportFlags(flags);
104
+ const ctx = this.makeCtx();
105
+ const payload = buildReportPayload(resolved, (m) => this.error(m));
106
+ const response = await ctx.spinner(REPORTS.FETCH_SPINNER, flags.json, () => ctx.post(API_ENDPOINTS.REPORTS, payload));
107
+ if (!response.ok)
108
+ ctx.onApiError(response);
109
+ if (flags.json) {
110
+ this.log(JSON.stringify(response.data, null, 2));
111
+ return;
112
+ }
113
+ renderReport(ctx, response.data, resolved.output);
114
+ }
115
+ makeCtx() {
116
+ return {
117
+ log: (msg) => this.log(msg),
118
+ onApiError: (r) => this.handleApiError(r),
119
+ post: (path, body) => this.apiClient.post(path, body),
120
+ spinner: (text, json, work) => this.withApiSpinner({ json, text }, work),
121
+ };
122
+ }
123
+ }
@@ -8,6 +8,8 @@ export interface ApiRequestDebugInfo {
8
8
  causeCode?: string;
9
9
  /** Fully resolved request URL (origin, path, and query string). */
10
10
  fullUrl: string;
11
+ /** Truncated JSON summary of the request body sent, when present. */
12
+ requestBody?: string;
11
13
  /** Truncated JSON summary of a non-empty error response body, when available. */
12
14
  responseSummary?: string;
13
15
  }
@@ -82,12 +82,12 @@ export class ApiClient {
82
82
  const data = await response.json().catch(() => ({}));
83
83
  if (!response.ok) {
84
84
  const errorData = data;
85
- const summary = summarizeResponseBody(data);
86
85
  return {
87
86
  data: data,
88
87
  debug: {
89
88
  ...debug,
90
- responseSummary: summary,
89
+ requestBody: summarizeResponseBody(body),
90
+ responseSummary: summarizeResponseBody(data),
91
91
  },
92
92
  error: errorData?.message ||
93
93
  errorData?.error ||
@@ -68,25 +68,22 @@ export declare abstract class BaseCommand extends Command {
68
68
  * settings and all email sequence definitions.
69
69
  */
70
70
  protected ensureYaml(): Promise<MailmodoYaml>;
71
+ protected isBlankDirectory(): boolean;
72
+ protected fetchYamlText(): Promise<null | string>;
73
+ private promptBlankDirRestore;
74
+ protected promptInitServerRestore(flags: {
75
+ json?: boolean;
76
+ yes?: boolean;
77
+ }): Promise<{
78
+ restored: boolean;
79
+ serverYaml: MailmodoYaml | null;
80
+ }>;
71
81
  private fetchAndWriteYaml;
72
82
  /**
73
83
  * Attempts to fetch mailmodo.yaml from the server and save it locally.
74
84
  * Returns null silently on any failure so callers can fall through to an error.
75
85
  */
76
86
  private restoreYamlFromServer;
77
- /**
78
- * If `mailmodo.yaml` is absent from the current directory, attempts to restore
79
- * it from the server using the given client. Returns `true` if the file was
80
- * successfully written, `false` otherwise (file already present, server 404,
81
- * or any network error). Silent — never throws.
82
- *
83
- * After ensuring the YAML is available, also silently restores any template
84
- * files that are missing from the local mailmodo/ folder, so a returning user
85
- * gets both their config and templates back without having to run init again.
86
- *
87
- * Used by `mailmodo login` right after the API key is validated.
88
- */
89
- protected recoverYamlAfterLogin(client: ApiClient): Promise<boolean>;
90
87
  /**
91
88
  * Uploads the current local mailmodo.yaml to the server as a backup.
92
89
  * Best-effort: silently ignores all errors so the originating command
@@ -1,17 +1,17 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readFile, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { input } from '@inquirer/prompts';
4
+ import { input, select } from '@inquirer/prompts';
5
5
  import { Command, Flags } from '@oclif/core';
6
6
  import chalk from 'chalk';
7
7
  import ora from 'ora';
8
8
  import { ApiClient } from './api-client.js';
9
9
  import { loadConfig } from './config.js';
10
- import { API_ENDPOINTS, IS_DEV_MODE, YAML_FILE } from './constants.js';
10
+ import { API_ENDPOINTS, IS_DEV_MODE, TEMPLATES_DIR, YAML_FILE, } from './constants.js';
11
11
  import { syncTemplatesToServer, syncTemplateToServer, fetchTemplatesFromServer, fetchTemplateFromServer, } from './templates/sync.js';
12
12
  import { getMissingTemplateIds } from './templates/missing-templates.js';
13
- import { ERRORS, INFO, PROMPTS, quotaExhaustedMessage, recordLabel, VALIDATION, } from './messages.js';
14
- import { loadYaml, saveYaml } from './yaml-config.js';
13
+ import { BLANK_DIR, ERRORS, INFO, PROMPTS, quotaExhaustedMessage, recordLabel, VALIDATION, } from './messages.js';
14
+ import { loadYaml, parseYamlText, saveYaml, } from './yaml-config.js';
15
15
  export const FREE_TIER = 'free';
16
16
  /**
17
17
  * Abstract base command providing shared functionality for all Mailmodo CLI commands.
@@ -92,11 +92,90 @@ export class BaseCommand extends Command {
92
92
  const config = await loadYaml();
93
93
  if (config)
94
94
  return config;
95
+ if (this.isBlankDirectory())
96
+ return this.promptBlankDirRestore();
95
97
  const restored = await this.restoreYamlFromServer();
96
98
  if (restored)
97
99
  return restored;
98
100
  this.error(ERRORS.NO_YAML);
99
101
  }
102
+ isBlankDirectory() {
103
+ return (!existsSync(join(process.cwd(), YAML_FILE)) &&
104
+ !existsSync(join(process.cwd(), TEMPLATES_DIR)));
105
+ }
106
+ async fetchYamlText() {
107
+ try {
108
+ let client = this.apiClient;
109
+ if (!client) {
110
+ const apiKey = process.env.MAILMODO_API_KEY ?? (await loadConfig())?.apiKey;
111
+ if (!apiKey)
112
+ return null;
113
+ client = new ApiClient(apiKey);
114
+ }
115
+ const response = await client.getRawText(API_ENDPOINTS.ASSETS_YAML);
116
+ return response.ok && response.data ? response.data : null;
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
122
+ async promptBlankDirRestore() {
123
+ const yamlText = await this.fetchYamlText();
124
+ if (!yamlText)
125
+ this.error(ERRORS.NO_YAML);
126
+ const autoRestore = this.argv.includes('--yes') || this.argv.includes('--json');
127
+ if (!autoRestore) {
128
+ this.log(`\n ${BLANK_DIR.PROMPT}\n`);
129
+ const choice = await select({
130
+ choices: [
131
+ { name: BLANK_DIR.CHOICE_RESTORE, value: 'restore' },
132
+ { name: BLANK_DIR.CHOICE_SKIP, value: 'skip' },
133
+ ],
134
+ message: 'How would you like to proceed?',
135
+ });
136
+ if (choice === 'skip') {
137
+ this.log(`\n ${BLANK_DIR.SKIP_HINT}\n`);
138
+ this.exit(0);
139
+ }
140
+ }
141
+ await writeFile(join(process.cwd(), YAML_FILE), yamlText);
142
+ this.logToStderr(INFO.YAML_RESTORED_FROM_SERVER);
143
+ const yaml = await loadYaml();
144
+ if (!yaml)
145
+ this.error(ERRORS.NO_YAML);
146
+ const client = this.apiClient;
147
+ if (client)
148
+ await this.fetchMissingTemplates(client, yaml);
149
+ return yaml;
150
+ }
151
+ async promptInitServerRestore(flags) {
152
+ const yamlText = await this.fetchYamlText();
153
+ if (!yamlText)
154
+ return { restored: false, serverYaml: null };
155
+ let shouldRestore = Boolean(flags.yes || flags.json);
156
+ if (!shouldRestore) {
157
+ const choice = await select({
158
+ choices: [
159
+ { name: BLANK_DIR.CHOICE_RESTORE_INIT, value: 'restore' },
160
+ { name: BLANK_DIR.CHOICE_FRESH, value: 'fresh' },
161
+ ],
162
+ message: BLANK_DIR.PROMPT_INIT,
163
+ });
164
+ shouldRestore = choice === 'restore';
165
+ }
166
+ if (!shouldRestore) {
167
+ return { restored: false, serverYaml: parseYamlText(yamlText) };
168
+ }
169
+ await writeFile(join(process.cwd(), YAML_FILE), yamlText);
170
+ const yaml = await loadYaml();
171
+ if (!yaml)
172
+ return { restored: false, serverYaml: null };
173
+ const client = this.apiClient;
174
+ if (client)
175
+ await this.fetchMissingTemplates(client, yaml);
176
+ this.log(`\n ${BLANK_DIR.RESTORED_INIT}\n`);
177
+ return { restored: true, serverYaml: yaml };
178
+ }
100
179
  async fetchAndWriteYaml(client) {
101
180
  try {
102
181
  const response = await client.getRawText(API_ENDPOINTS.ASSETS_YAML);
@@ -133,29 +212,6 @@ export class BaseCommand extends Command {
133
212
  return null;
134
213
  }
135
214
  }
136
- /**
137
- * If `mailmodo.yaml` is absent from the current directory, attempts to restore
138
- * it from the server using the given client. Returns `true` if the file was
139
- * successfully written, `false` otherwise (file already present, server 404,
140
- * or any network error). Silent — never throws.
141
- *
142
- * After ensuring the YAML is available, also silently restores any template
143
- * files that are missing from the local mailmodo/ folder, so a returning user
144
- * gets both their config and templates back without having to run init again.
145
- *
146
- * Used by `mailmodo login` right after the API key is validated.
147
- */
148
- async recoverYamlAfterLogin(client) {
149
- let yamlRestored = false;
150
- if (!existsSync(join(process.cwd(), YAML_FILE))) {
151
- yamlRestored = await this.fetchAndWriteYaml(client);
152
- }
153
- // Always check for missing templates, whether YAML was just restored or already present
154
- const yaml = await loadYaml();
155
- if (yaml)
156
- await this.fetchMissingTemplates(client, yaml);
157
- return yamlRestored;
158
- }
159
215
  /**
160
216
  * Uploads the current local mailmodo.yaml to the server as a backup.
161
217
  * Best-effort: silently ignores all errors so the originating command
@@ -502,6 +558,9 @@ export class BaseCommand extends Command {
502
558
  status > 0
503
559
  ? chalk.dim(` Status: ${String(status)}`)
504
560
  : chalk.dim(' Status: (No HTTP response — network or client error)'),
561
+ ...(debug.requestBody
562
+ ? [chalk.dim(` Payload: ${debug.requestBody}`)]
563
+ : []),
505
564
  ...(debug.responseSummary
506
565
  ? [chalk.dim(` Response: ${debug.responseSummary}`)]
507
566
  : []),
@@ -1,8 +1,8 @@
1
1
  import type { LoginCtx, ValidateResponse } from './types.js';
2
2
  import type { MailmodoConfig } from '../../config.js';
3
- export declare function logAlreadyLoggedIn(ctx: LoginCtx, existing: MailmodoConfig, yamlRestored: boolean, opts: {
3
+ export declare function logAlreadyLoggedIn(ctx: LoginCtx, existing: MailmodoConfig, opts: {
4
4
  json: boolean;
5
5
  }): void;
6
- export declare function logLoginSuccess(ctx: LoginCtx, data: Pick<ValidateResponse, 'email' | 'paidEmailsRemaining' | 'plan' | 'totalFreeRemaining'>, yamlRestored: boolean, opts: {
6
+ export declare function logLoginSuccess(ctx: LoginCtx, data: Pick<ValidateResponse, 'email' | 'paidEmailsRemaining' | 'plan' | 'totalFreeRemaining'>, opts: {
7
7
  json: boolean;
8
8
  }): void;
@@ -1,12 +1,10 @@
1
1
  import chalk from 'chalk';
2
- import { INFO } from '../../messages.js';
3
- export function logAlreadyLoggedIn(ctx, existing, yamlRestored, opts) {
2
+ export function logAlreadyLoggedIn(ctx, existing, opts) {
4
3
  if (opts.json) {
5
4
  ctx.log(JSON.stringify({
6
5
  email: existing.email ?? null,
7
6
  status: 'already_logged_in',
8
7
  totalFreeRemaining: existing.totalFreeRemaining ?? null,
9
- yamlRestored,
10
8
  }, null, 2));
11
9
  return;
12
10
  }
@@ -15,15 +13,10 @@ export function logAlreadyLoggedIn(ctx, existing, yamlRestored, opts) {
15
13
  ? chalk.green(existing.email.trim())
16
14
  : chalk.dim('(unknown)');
17
15
  ctx.log(` Email: ${emailDisplay}\n`);
18
- if (yamlRestored) {
19
- ctx.log(` ${INFO.YAML_RESTORED_ON_LOGIN}`);
20
- }
21
- else {
22
- ctx.log(` ${chalk.dim('1.')} Run ${chalk.cyan('mailmodo init')} to generate an email sequence.`);
23
- }
24
- ctx.log(` ${chalk.dim(yamlRestored ? '1.' : '2.')} Run ${chalk.cyan('mailmodo logout')} to log in with another account.\n`);
16
+ ctx.log(` ${chalk.dim('1.')} Run ${chalk.cyan('mailmodo init')} to generate an email sequence.`);
17
+ ctx.log(` ${chalk.dim('2.')} Run ${chalk.cyan('mailmodo logout')} to log in with another account.\n`);
25
18
  }
26
- export function logLoginSuccess(ctx, data, yamlRestored, opts) {
19
+ export function logLoginSuccess(ctx, data, opts) {
27
20
  const { email, plan, totalFreeRemaining, paidEmailsRemaining } = data;
28
21
  if (opts.json) {
29
22
  ctx.log(JSON.stringify({
@@ -32,7 +25,6 @@ export function logLoginSuccess(ctx, data, yamlRestored, opts) {
32
25
  plan,
33
26
  status: 'authenticated',
34
27
  totalFreeRemaining,
35
- yamlRestored,
36
28
  }, null, 2));
37
29
  return;
38
30
  }
@@ -44,10 +36,5 @@ export function logLoginSuccess(ctx, data, yamlRestored, opts) {
44
36
  if (plan === 'paid') {
45
37
  ctx.log(` Current paid block: ${chalk.cyan(String(paidEmailsRemaining))} emails remaining\n`);
46
38
  }
47
- if (yamlRestored) {
48
- ctx.log(` ${INFO.YAML_RESTORED_ON_LOGIN}\n`);
49
- }
50
- else {
51
- ctx.log(` Next: Run ${chalk.cyan("'mailmodo init'")} to generate your email sequences.\n`);
52
- }
39
+ ctx.log(` Next: Run ${chalk.cyan("'mailmodo init'")} to generate your email sequences.\n`);
53
40
  }
@@ -0,0 +1,2 @@
1
+ import type { EntriesResponse, ReportCtx } from './types.js';
2
+ export declare function renderEntries(ctx: ReportCtx, data: EntriesResponse): void;
@@ -0,0 +1,59 @@
1
+ import chalk from 'chalk';
2
+ const TIME_W = 18;
3
+ const EMAIL_W = 24;
4
+ const SEQ_W = 12;
5
+ const STATUS_W = 14;
6
+ const CONTACT_COL = TIME_W + EMAIL_W + SEQ_W + STATUS_W;
7
+ function statusColor(status) {
8
+ switch (status) {
9
+ case 'bounced':
10
+ case 'complained': {
11
+ return chalk.red;
12
+ }
13
+ case 'clicked':
14
+ case 'delivered':
15
+ case 'opened':
16
+ case 'sent': {
17
+ return chalk.green;
18
+ }
19
+ case 'skipped': {
20
+ return chalk.yellow;
21
+ }
22
+ default: {
23
+ return chalk.white;
24
+ }
25
+ }
26
+ }
27
+ function truncate(s, width) {
28
+ return s.length > width ? s.slice(0, width - 1) + '…' : s;
29
+ }
30
+ function shortSeq(id) {
31
+ return id.length > 8 ? id.slice(0, 8) + '…' : id;
32
+ }
33
+ export function renderEntries(ctx, data) {
34
+ const { entries, limit, page, total } = data;
35
+ ctx.log(`\n ${'Time'.padEnd(TIME_W)}${'Template'.padEnd(EMAIL_W)}` +
36
+ `${'Sequence'.padEnd(SEQ_W)}${'Status'.padEnd(STATUS_W)}Contact`);
37
+ ctx.log(` ${'─'.repeat(CONTACT_COL + 28)}`);
38
+ if (!entries?.length) {
39
+ ctx.log(` ${chalk.dim('No entries found.')}`);
40
+ ctx.log('');
41
+ return;
42
+ }
43
+ for (const entry of entries) {
44
+ const time = (entry.timestamp || '').padEnd(TIME_W);
45
+ const email = truncate(entry.emailId || '', EMAIL_W - 2).padEnd(EMAIL_W);
46
+ const seq = chalk.dim(shortSeq(entry.sequenceId || '').padEnd(SEQ_W));
47
+ const status = statusColor(entry.status)((entry.status || '').padEnd(STATUS_W));
48
+ ctx.log(` ${time}${email}${seq}${status}${entry.contact || ''}`);
49
+ if (entry.reason) {
50
+ const label = entry.status === 'skipped' ? 'condition not met' : 'reason';
51
+ ctx.log(` ${' '.repeat(CONTACT_COL)}${chalk.dim(`└ ${label}: ${entry.reason}`)}`);
52
+ }
53
+ }
54
+ const totalPages = Math.ceil(total / limit);
55
+ ctx.log(`\n Page ${page} of ${totalPages} · ${total} total entries`);
56
+ if (page < totalPages)
57
+ ctx.log(` ${chalk.dim(`Next: --page ${page + 1}`)}`);
58
+ ctx.log('');
59
+ }
@@ -0,0 +1,2 @@
1
+ import type { ReportCtx, TimeseriesResponse } from './types.js';
2
+ export declare function renderTimeseries(ctx: ReportCtx, data: TimeseriesResponse): void;
@@ -0,0 +1,28 @@
1
+ import chalk from 'chalk';
2
+ function fmtRate(n) {
3
+ return (n * 100).toFixed(1) + '%';
4
+ }
5
+ export function renderTimeseries(ctx, data) {
6
+ const { bucket, series, timeRange } = data;
7
+ const from = timeRange.from.slice(0, 10);
8
+ const to = timeRange.to.slice(0, 10);
9
+ ctx.log(`\n ${chalk.dim(`Bucket: ${bucket} (${from} → ${to})`)}`);
10
+ ctx.log(`\n ${'Time'.padEnd(22)}${'Sent'.padEnd(7)}${'Delivered'.padEnd(11)}${'Opened'.padEnd(8)}${'Clicked'.padEnd(9)}${'OpenRate'.padEnd(10)}${'ClickRate'.padEnd(10)}BounceRate`);
11
+ ctx.log(` ${'─'.repeat(87)}`);
12
+ if (!series?.length) {
13
+ ctx.log(` ${chalk.dim('No timeseries data.')}`);
14
+ ctx.log('');
15
+ return;
16
+ }
17
+ for (const point of series) {
18
+ ctx.log(` ${(point.ts || '').padEnd(22)}` +
19
+ `${String(point.sent).padEnd(7)}` +
20
+ `${String(point.delivered).padEnd(11)}` +
21
+ `${String(point.opened).padEnd(8)}` +
22
+ `${String(point.clicked).padEnd(9)}` +
23
+ `${fmtRate(point.openRate).padEnd(10)}` +
24
+ `${fmtRate(point.clickRate).padEnd(10)}` +
25
+ `${fmtRate(point.bounceRate)}`);
26
+ }
27
+ ctx.log('');
28
+ }
@@ -0,0 +1,3 @@
1
+ import type { ReportCtx, ReportResponse, SummaryResponse } from './types.js';
2
+ export declare function renderSummary(ctx: ReportCtx, data: SummaryResponse): void;
3
+ export declare function renderReport(ctx: ReportCtx, data: ReportResponse, mode: string): void;
@@ -0,0 +1,56 @@
1
+ import chalk from 'chalk';
2
+ import { renderEntries } from './output-entries.js';
3
+ import { renderTimeseries } from './output-timeseries.js';
4
+ function fmtRate(n) {
5
+ return (n * 100).toFixed(1) + '%';
6
+ }
7
+ function statsColumns(s) {
8
+ return [
9
+ String(s.sent).padEnd(7),
10
+ String(s.delivered).padEnd(11),
11
+ String(s.opened).padEnd(8),
12
+ String(s.clicked).padEnd(9),
13
+ String(s.bounced).padEnd(9),
14
+ fmtRate(s.openRate).padEnd(10),
15
+ fmtRate(s.clickRate).padEnd(10),
16
+ fmtRate(s.bounceRate),
17
+ ].join('');
18
+ }
19
+ function renderGroupedTable(ctx, data) {
20
+ ctx.log(`\n ${'Key'.padEnd(20)}${'Sent'.padEnd(7)}${'Delivered'.padEnd(11)}` +
21
+ `${'Opened'.padEnd(8)}${'Clicked'.padEnd(9)}${'Bounced'.padEnd(9)}` +
22
+ `${'OpenRate'.padEnd(10)}${'ClickRate'.padEnd(10)}BounceRate`);
23
+ ctx.log(` ${'─'.repeat(93)}`);
24
+ for (const g of data.groups) {
25
+ const key = g.key.slice(0, 19).padEnd(20);
26
+ ctx.log(` ${key}${statsColumns(g)}`);
27
+ }
28
+ ctx.log(` ${'─'.repeat(93)}`);
29
+ ctx.log(` ${chalk.bold('TOTAL'.padEnd(20))}${statsColumns(data.totals)}`);
30
+ }
31
+ function renderTotalsOnly(ctx, data) {
32
+ const t = data.totals;
33
+ ctx.log(`\n Sent: ${t.sent} Delivered: ${t.delivered} Opened: ${t.opened}` +
34
+ ` Clicked: ${t.clicked} Bounced: ${t.bounced}`);
35
+ ctx.log(` Open rate: ${fmtRate(t.openRate)} Click rate: ${fmtRate(t.clickRate)}` +
36
+ ` Bounce rate: ${fmtRate(t.bounceRate)}`);
37
+ }
38
+ export function renderSummary(ctx, data) {
39
+ const { from, to } = data.timeRange;
40
+ ctx.log(`\n ${chalk.dim(`${from.slice(0, 10)} → ${to.slice(0, 10)}`)}`);
41
+ if (data.groups.length > 0) {
42
+ renderGroupedTable(ctx, data);
43
+ }
44
+ else {
45
+ renderTotalsOnly(ctx, data);
46
+ }
47
+ ctx.log('');
48
+ }
49
+ export function renderReport(ctx, data, mode) {
50
+ if (mode === 'entries')
51
+ renderEntries(ctx, data);
52
+ else if (mode === 'timeseries')
53
+ renderTimeseries(ctx, data);
54
+ else
55
+ renderSummary(ctx, data);
56
+ }
@@ -0,0 +1,2 @@
1
+ import type { ReportFlags, ReportPayload } from './types.js';
2
+ export declare function buildReportPayload(flags: ReportFlags, onError: (m: string) => never): ReportPayload;
@@ -0,0 +1,49 @@
1
+ import { REPORTS } from '../../messages.js';
2
+ function buildTimeRange(flags) {
3
+ if (flags.preset)
4
+ return { preset: flags.preset };
5
+ if (flags.from && flags.to)
6
+ return { from: flags.from, to: flags.to };
7
+ return undefined;
8
+ }
9
+ function buildFilters(flags) {
10
+ const f = {};
11
+ if (flags.sequence?.length)
12
+ f.sequenceIds = flags.sequence;
13
+ if (flags['email-id']?.length)
14
+ f.emailIds = flags['email-id'];
15
+ if (flags.contact?.length)
16
+ f.contactEmails = flags.contact;
17
+ if (flags.event?.length)
18
+ f.events = flags.event;
19
+ return Object.keys(f).length > 0 ? f : undefined;
20
+ }
21
+ function validateFilterSizes(flags, onError) {
22
+ const arrays = [
23
+ flags.sequence,
24
+ flags['email-id'],
25
+ flags.contact,
26
+ flags.event,
27
+ ];
28
+ if (arrays.some((a) => a && a.length > 100))
29
+ onError(REPORTS.TOO_MANY_ITEMS);
30
+ }
31
+ export function buildReportPayload(flags, onError) {
32
+ validateFilterSizes(flags, onError);
33
+ const timeRange = buildTimeRange(flags);
34
+ if (!timeRange)
35
+ onError(REPORTS.TIME_RANGE_REQUIRED);
36
+ const payload = {
37
+ groupBy: flags['group-by'],
38
+ output: flags.output,
39
+ timeRange,
40
+ };
41
+ const filters = buildFilters(flags);
42
+ if (filters)
43
+ payload.filters = filters;
44
+ if (flags.output === 'entries') {
45
+ payload.page = flags.page;
46
+ payload.limit = flags.limit;
47
+ }
48
+ return payload;
49
+ }
@@ -0,0 +1,2 @@
1
+ import type { ReportFlags } from './types.js';
2
+ export declare function promptReportFlags(flags: ReportFlags): Promise<ReportFlags>;
@@ -0,0 +1,82 @@
1
+ import { input, select } from '@inquirer/prompts';
2
+ const PRESET_CHOICES = [
3
+ { name: 'Today', value: 'today' },
4
+ { name: 'Yesterday', value: 'yesterday' },
5
+ { name: 'Last 7 days', value: 'last7d' },
6
+ { name: 'Last 30 days', value: 'last30d' },
7
+ { name: 'Last 90 days', value: 'last90d' },
8
+ { name: 'This month', value: 'thisMonth' },
9
+ { name: 'Last month', value: 'lastMonth' },
10
+ ];
11
+ const OUTPUT_CHOICES = [
12
+ { name: 'Summary — grouped aggregates', value: 'summary' },
13
+ { name: 'Entries — paginated event log', value: 'entries' },
14
+ { name: 'Timeseries — bucketed over time', value: 'timeseries' },
15
+ ];
16
+ const GROUP_BY_CHOICES = [
17
+ { name: 'None (totals only)', value: 'none' },
18
+ { name: 'Email template', value: 'emailId' },
19
+ { name: 'Sequence ID', value: 'sequenceId' },
20
+ { name: 'Day', value: 'day' },
21
+ { name: 'Hour', value: 'hour' },
22
+ { name: 'Contact', value: 'contact' },
23
+ { name: 'Status', value: 'status' },
24
+ ];
25
+ function isValidIsoDate(value) {
26
+ if (!value?.trim())
27
+ return 'Date is required';
28
+ if (Number.isNaN(new Date(value).getTime()))
29
+ return 'Enter a valid date in YYYY-MM-DD format';
30
+ return true;
31
+ }
32
+ async function promptTimeRange() {
33
+ const usePreset = await select({
34
+ choices: [
35
+ { name: 'Use a preset (today, last7d, last30d…)', value: true },
36
+ { name: 'Enter a custom date range', value: false },
37
+ ],
38
+ message: 'Time range:',
39
+ });
40
+ if (usePreset) {
41
+ const preset = await select({
42
+ choices: PRESET_CHOICES,
43
+ message: 'Preset:',
44
+ });
45
+ return { preset };
46
+ }
47
+ const from = await input({
48
+ message: 'Start date (YYYY-MM-DD):',
49
+ validate: isValidIsoDate,
50
+ });
51
+ const to = await input({
52
+ message: 'End date, exclusive (YYYY-MM-DD):',
53
+ validate(v) {
54
+ const check = isValidIsoDate(v);
55
+ if (check !== true)
56
+ return check;
57
+ if (new Date(v) <= new Date(from))
58
+ return 'End date must be after start date';
59
+ return true;
60
+ },
61
+ });
62
+ return { from, to };
63
+ }
64
+ export async function promptReportFlags(flags) {
65
+ const hasTimeRange = Boolean(flags.preset || (flags.from && flags.to));
66
+ if (hasTimeRange)
67
+ return flags;
68
+ const timeRange = await promptTimeRange();
69
+ const output = await select({
70
+ choices: OUTPUT_CHOICES,
71
+ default: flags.output,
72
+ message: 'Output mode:',
73
+ });
74
+ const groupBy = output === 'summary'
75
+ ? await select({
76
+ choices: GROUP_BY_CHOICES,
77
+ default: flags['group-by'],
78
+ message: 'Group by:',
79
+ })
80
+ : 'none';
81
+ return { ...flags, ...timeRange, 'group-by': groupBy, output };
82
+ }
@@ -0,0 +1,97 @@
1
+ import type { ApiResponse } from '../../api-client.js';
2
+ export interface ReportFilters {
3
+ contactEmails?: string[];
4
+ emailIds?: string[];
5
+ events?: string[];
6
+ sequenceIds?: string[];
7
+ }
8
+ export type ReportTimeRange = {
9
+ from: string;
10
+ to: string;
11
+ } | {
12
+ preset: string;
13
+ };
14
+ export interface ReportPayload {
15
+ filters?: ReportFilters;
16
+ groupBy: string;
17
+ limit?: number;
18
+ output: string;
19
+ page?: number;
20
+ timeRange?: ReportTimeRange;
21
+ }
22
+ export interface ReportGroup {
23
+ bounceRate: number;
24
+ bounced: number;
25
+ clickRate: number;
26
+ clicked: number;
27
+ complained: number;
28
+ delivered: number;
29
+ key: string;
30
+ openRate: number;
31
+ opened: number;
32
+ sent: number;
33
+ skipped: number;
34
+ unsubscribed: number;
35
+ }
36
+ export type ReportTotals = Omit<ReportGroup, 'key'>;
37
+ export interface SummaryResponse {
38
+ groupBy: string;
39
+ groups: ReportGroup[];
40
+ timeRange: {
41
+ from: string;
42
+ to: string;
43
+ };
44
+ totals: ReportTotals;
45
+ }
46
+ export interface EntryRecord {
47
+ contact: string;
48
+ emailId: string;
49
+ reason?: null | string;
50
+ sequenceId: string;
51
+ status: string;
52
+ timestamp: string;
53
+ }
54
+ export interface EntriesResponse {
55
+ entries: EntryRecord[];
56
+ limit: number;
57
+ page: number;
58
+ timeRange: {
59
+ from: string;
60
+ to: string;
61
+ };
62
+ total: number;
63
+ }
64
+ export type TimeseriesPoint = ReportTotals & {
65
+ ts: string;
66
+ };
67
+ export interface TimeseriesResponse {
68
+ bucket: string;
69
+ series: TimeseriesPoint[];
70
+ timeRange: {
71
+ from: string;
72
+ to: string;
73
+ };
74
+ }
75
+ export type ReportResponse = EntriesResponse | SummaryResponse | TimeseriesResponse;
76
+ export type ReportFlags = {
77
+ contact?: string[];
78
+ 'email-id'?: string[];
79
+ event?: string[];
80
+ from?: string;
81
+ 'group-by': string;
82
+ limit: number;
83
+ output: string;
84
+ page: number;
85
+ preset?: string;
86
+ sequence?: string[];
87
+ to?: string;
88
+ };
89
+ export type ReportCtx = {
90
+ log(msg?: string): void;
91
+ onApiError(resp: {
92
+ error?: string;
93
+ status: number;
94
+ }): never;
95
+ post<T = Record<string, unknown>>(path: string, body?: unknown): Promise<ApiResponse<T>>;
96
+ spinner<T>(text: string, json: boolean, work: () => Promise<T>): Promise<T>;
97
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -28,6 +28,8 @@ export declare function saveConfig(config: MailmodoConfig): Promise<void>;
28
28
  export declare function clearConfig(): Promise<void>;
29
29
  /**
30
30
  * Returns the absolute path to the Mailmodo config directory (~/.mailmodo).
31
+ * Honors MAILMODO_CONFIG_DIR env var when set (used in tests to redirect away
32
+ * from the user's real config).
31
33
  *
32
34
  * @returns {string} The config directory path.
33
35
  */
@@ -2,8 +2,12 @@ import { existsSync } from 'node:fs';
2
2
  import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- const CONFIG_DIR = join(homedir(), '.mailmodo');
6
- const CONFIG_FILE = join(CONFIG_DIR, 'config');
5
+ function configDir() {
6
+ return process.env.MAILMODO_CONFIG_DIR ?? join(homedir(), '.mailmodo');
7
+ }
8
+ function configFile() {
9
+ return join(configDir(), 'config');
10
+ }
7
11
  /**
8
12
  * Loads the Mailmodo CLI configuration from ~/.mailmodo/config.
9
13
  * The config file stores the API key and account metadata as JSON.
@@ -13,10 +17,11 @@ const CONFIG_FILE = join(CONFIG_DIR, 'config');
13
17
  * or null if the config file does not exist or is corrupted.
14
18
  */
15
19
  export async function loadConfig() {
16
- if (!existsSync(CONFIG_FILE))
20
+ const file = configFile();
21
+ if (!existsSync(file))
17
22
  return null;
18
23
  try {
19
- const content = await readFile(CONFIG_FILE, 'utf8');
24
+ const content = await readFile(file, 'utf8');
20
25
  return JSON.parse(content);
21
26
  }
22
27
  catch {
@@ -32,20 +37,22 @@ export async function loadConfig() {
32
37
  * at minimum an apiKey. Optional fields: email, totalFreeRemaining.
33
38
  */
34
39
  export async function saveConfig(config) {
35
- if (!existsSync(CONFIG_DIR)) {
36
- await mkdir(CONFIG_DIR, { recursive: true });
40
+ const dir = configDir();
41
+ if (!existsSync(dir)) {
42
+ await mkdir(dir, { recursive: true });
37
43
  }
38
- await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
44
+ await writeFile(configFile(), JSON.stringify(config, null, 2));
39
45
  }
40
46
  /**
41
47
  * Deletes the saved CLI config file (~/.mailmodo/config), removing the stored API key.
42
48
  * No-op if the file does not exist.
43
49
  */
44
50
  export async function clearConfig() {
45
- if (!existsSync(CONFIG_FILE))
51
+ const file = configFile();
52
+ if (!existsSync(file))
46
53
  return;
47
54
  try {
48
- await unlink(CONFIG_FILE);
55
+ await unlink(file);
49
56
  }
50
57
  catch {
51
58
  // Ignore missing file or permission errors; caller can treat as best-effort sign-out.
@@ -53,9 +60,11 @@ export async function clearConfig() {
53
60
  }
54
61
  /**
55
62
  * Returns the absolute path to the Mailmodo config directory (~/.mailmodo).
63
+ * Honors MAILMODO_CONFIG_DIR env var when set (used in tests to redirect away
64
+ * from the user's real config).
56
65
  *
57
66
  * @returns {string} The config directory path.
58
67
  */
59
68
  export function getConfigDir() {
60
- return CONFIG_DIR;
69
+ return configDir();
61
70
  }
@@ -26,6 +26,7 @@ export declare const API_ENDPOINTS: Readonly<{
26
26
  GENERATE: "/email/generate";
27
27
  LOGS: "/logs";
28
28
  PREVIEW: "/preview";
29
+ REPORTS: "/reports";
29
30
  SEQUENCES: "/sequences";
30
31
  SEQUENCES_DEPLOY: "/sequences/deploy";
31
32
  SEQUENCES_SDK: "/sequences/sdk";
@@ -31,6 +31,7 @@ export const API_ENDPOINTS = Object.freeze({
31
31
  GENERATE: '/email/generate',
32
32
  LOGS: '/logs',
33
33
  PREVIEW: '/preview',
34
+ REPORTS: '/reports',
34
35
  SEQUENCES: '/sequences',
35
36
  SEQUENCES_DEPLOY: '/sequences/deploy',
36
37
  SEQUENCES_SDK: '/sequences/sdk',
@@ -13,6 +13,16 @@ export declare const PROMPTS: {
13
13
  readonly REPLY_TO: "Reply-to address (optional, press Enter to use sender email):";
14
14
  readonly SENDER_EMAIL: "Sender email address:";
15
15
  };
16
+ export declare const BLANK_DIR: {
17
+ readonly CHOICE_FRESH: "Start fresh — new emails will be generated (your domain, sender, and address settings will be kept)";
18
+ readonly CHOICE_RESTORE: "Restore project files from server";
19
+ readonly CHOICE_RESTORE_INIT: "Restore existing project files from server";
20
+ readonly CHOICE_SKIP: "Skip — I'll navigate to my project directory manually";
21
+ readonly PROMPT: "No mailmodo.yaml or templates found in this directory.";
22
+ readonly PROMPT_INIT: "A project was found on the server. What would you like to do?";
23
+ readonly RESTORED_INIT: `Project restored from server. Run ${string} to re-deploy, or ${string} to review your emails.`;
24
+ readonly SKIP_HINT: `Navigate to your project directory and run your command there, or run ${string} to start a new project here.`;
25
+ };
16
26
  export declare const MISSING_TEMPLATES: {
17
27
  readonly ABORT_HINT: `Restore the missing files from version control, then run ${string} again.`;
18
28
  readonly CHOICE_ABORT: "Abort (restore from version control)";
@@ -54,6 +64,13 @@ export declare const INFO: {
54
64
  readonly YAML_RESTORED_FROM_SERVER: string;
55
65
  readonly YAML_RESTORED_ON_LOGIN: ` mailmodo.yaml restored from server. Run ${string} to re-deploy your sequences.`;
56
66
  };
67
+ export declare const REPORTS: {
68
+ readonly FETCH_SPINNER: " Fetching report...";
69
+ readonly FROM_TO_REQUIRED: "--from and --to must both be provided together.";
70
+ readonly NO_DATA: "No data found for the given filters and time range.";
71
+ readonly TIME_RANGE_REQUIRED: "A time range is required. Use --preset or --from/--to.";
72
+ readonly TOO_MANY_ITEMS: "Filter arrays cannot exceed 100 items each.";
73
+ };
57
74
  export declare const DEPLOY: {
58
75
  readonly CHANGES_HEADER: "Changes vs. last deployment:";
59
76
  readonly DEPLOYING_HEADER: "Deploying:";
@@ -14,6 +14,16 @@ export const PROMPTS = {
14
14
  REPLY_TO: 'Reply-to address (optional, press Enter to use sender email):',
15
15
  SENDER_EMAIL: 'Sender email address:',
16
16
  };
17
+ export const BLANK_DIR = {
18
+ CHOICE_FRESH: 'Start fresh — new emails will be generated (your domain, sender, and address settings will be kept)',
19
+ CHOICE_RESTORE: 'Restore project files from server',
20
+ CHOICE_RESTORE_INIT: 'Restore existing project files from server',
21
+ CHOICE_SKIP: "Skip — I'll navigate to my project directory manually",
22
+ PROMPT: 'No mailmodo.yaml or templates found in this directory.',
23
+ PROMPT_INIT: 'A project was found on the server. What would you like to do?',
24
+ RESTORED_INIT: `Project restored from server. Run ${chalk.cyan('mailmodo deploy')} to re-deploy, or ${chalk.cyan('mailmodo emails')} to review your emails.`,
25
+ SKIP_HINT: `Navigate to your project directory and run your command there, or run ${chalk.cyan('mailmodo init')} to start a new project here.`,
26
+ };
17
27
  export const MISSING_TEMPLATES = {
18
28
  ABORT_HINT: `Restore the missing files from version control, then run ${chalk.cyan('mailmodo deploy')} again.`,
19
29
  CHOICE_ABORT: 'Abort (restore from version control)',
@@ -54,6 +64,13 @@ export const INFO = {
54
64
  YAML_RESTORED_FROM_SERVER: chalk.dim(' mailmodo.yaml not found locally — restored from server.'),
55
65
  YAML_RESTORED_ON_LOGIN: ` mailmodo.yaml restored from server. Run ${chalk.cyan("'mailmodo deploy'")} to re-deploy your sequences.`,
56
66
  };
67
+ export const REPORTS = {
68
+ FETCH_SPINNER: ' Fetching report...',
69
+ FROM_TO_REQUIRED: '--from and --to must both be provided together.',
70
+ NO_DATA: 'No data found for the given filters and time range.',
71
+ TIME_RANGE_REQUIRED: 'A time range is required. Use --preset or --from/--to.',
72
+ TOO_MANY_ITEMS: 'Filter arrays cannot exceed 100 items each.',
73
+ };
57
74
  export const DEPLOY = {
58
75
  CHANGES_HEADER: 'Changes vs. last deployment:',
59
76
  DEPLOYING_HEADER: 'Deploying:',
@@ -44,6 +44,7 @@ export interface MailmodoYaml {
44
44
  * formatted Error with the line number if the file contains invalid YAML syntax.
45
45
  */
46
46
  export declare function loadYaml(cwd?: string): Promise<MailmodoYaml | null>;
47
+ export declare function parseYamlText(text: string): MailmodoYaml | null;
47
48
  /**
48
49
  * Serializes and writes the mailmodo.yaml configuration to disk.
49
50
  *
@@ -25,6 +25,14 @@ export async function loadYaml(cwd) {
25
25
  throw new Error(yamlParseError(error.message));
26
26
  }
27
27
  }
28
+ export function parseYamlText(text) {
29
+ try {
30
+ return load(text);
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
28
36
  /**
29
37
  * Serializes and writes the mailmodo.yaml configuration to disk.
30
38
  *
@@ -631,6 +631,173 @@
631
631
  "index.js"
632
632
  ]
633
633
  },
634
+ "report": {
635
+ "aliases": [],
636
+ "args": {},
637
+ "description": "Fetch an email analytics report",
638
+ "examples": [
639
+ "<%= config.bin %> report --preset last7d",
640
+ "<%= config.bin %> report --preset last30d --group-by emailId",
641
+ "<%= config.bin %> report --from YYYY-MM-DD --to YYYY-MM-DD --output timeseries",
642
+ "<%= config.bin %> report --preset last7d --output entries --page 1",
643
+ "<%= config.bin %> report --preset last30d --sequence welcome-flow --event opened",
644
+ "<%= config.bin %> report --preset last7d --json"
645
+ ],
646
+ "flags": {
647
+ "json": {
648
+ "description": "Output as JSON",
649
+ "name": "json",
650
+ "allowNo": false,
651
+ "type": "boolean"
652
+ },
653
+ "yes": {
654
+ "char": "y",
655
+ "description": "Skip confirmation prompts",
656
+ "name": "yes",
657
+ "allowNo": false,
658
+ "type": "boolean"
659
+ },
660
+ "contact": {
661
+ "description": "Filter by contact email (repeatable)",
662
+ "name": "contact",
663
+ "hasDynamicHelp": false,
664
+ "multiple": true,
665
+ "type": "option"
666
+ },
667
+ "email-id": {
668
+ "description": "Filter by email template ID (repeatable)",
669
+ "name": "email-id",
670
+ "hasDynamicHelp": false,
671
+ "multiple": true,
672
+ "type": "option"
673
+ },
674
+ "event": {
675
+ "description": "Filter by event type (repeatable)",
676
+ "name": "event",
677
+ "hasDynamicHelp": false,
678
+ "multiple": true,
679
+ "options": [
680
+ "bounced",
681
+ "clicked",
682
+ "complained",
683
+ "delivered",
684
+ "opened",
685
+ "sent",
686
+ "skipped",
687
+ "unsubscribed"
688
+ ],
689
+ "type": "option"
690
+ },
691
+ "from": {
692
+ "description": "Start of time range, inclusive (YYYY-MM-DD)",
693
+ "exclusive": [
694
+ "preset"
695
+ ],
696
+ "name": "from",
697
+ "hasDynamicHelp": false,
698
+ "multiple": false,
699
+ "type": "option"
700
+ },
701
+ "group-by": {
702
+ "description": "Group results by dimension",
703
+ "name": "group-by",
704
+ "default": "none",
705
+ "hasDynamicHelp": false,
706
+ "multiple": false,
707
+ "options": [
708
+ "contact",
709
+ "day",
710
+ "emailId",
711
+ "hour",
712
+ "none",
713
+ "sequenceId",
714
+ "status"
715
+ ],
716
+ "type": "option"
717
+ },
718
+ "limit": {
719
+ "description": "Entries per page, max 200 (entries output only)",
720
+ "name": "limit",
721
+ "default": 50,
722
+ "hasDynamicHelp": false,
723
+ "multiple": false,
724
+ "type": "option"
725
+ },
726
+ "output": {
727
+ "description": "Output shape: summary | entries | timeseries",
728
+ "name": "output",
729
+ "default": "summary",
730
+ "hasDynamicHelp": false,
731
+ "multiple": false,
732
+ "options": [
733
+ "entries",
734
+ "summary",
735
+ "timeseries"
736
+ ],
737
+ "type": "option"
738
+ },
739
+ "page": {
740
+ "description": "Page number (entries output only)",
741
+ "name": "page",
742
+ "default": 1,
743
+ "hasDynamicHelp": false,
744
+ "multiple": false,
745
+ "type": "option"
746
+ },
747
+ "preset": {
748
+ "description": "Relative time range preset",
749
+ "exclusive": [
750
+ "from",
751
+ "to"
752
+ ],
753
+ "name": "preset",
754
+ "hasDynamicHelp": false,
755
+ "multiple": false,
756
+ "options": [
757
+ "last30d",
758
+ "last7d",
759
+ "last90d",
760
+ "lastMonth",
761
+ "thisMonth",
762
+ "today",
763
+ "yesterday"
764
+ ],
765
+ "type": "option"
766
+ },
767
+ "sequence": {
768
+ "description": "Filter by sequence ID (repeatable)",
769
+ "name": "sequence",
770
+ "hasDynamicHelp": false,
771
+ "multiple": true,
772
+ "type": "option"
773
+ },
774
+ "to": {
775
+ "description": "End of time range, exclusive (YYYY-MM-DD)",
776
+ "exclusive": [
777
+ "preset"
778
+ ],
779
+ "name": "to",
780
+ "hasDynamicHelp": false,
781
+ "multiple": false,
782
+ "type": "option"
783
+ }
784
+ },
785
+ "hasDynamicHelp": false,
786
+ "hiddenAliases": [],
787
+ "id": "report",
788
+ "pluginAlias": "@mailmodo/cli",
789
+ "pluginName": "@mailmodo/cli",
790
+ "pluginType": "core",
791
+ "strict": true,
792
+ "enableJsonFlag": false,
793
+ "isESM": true,
794
+ "relativePath": [
795
+ "dist",
796
+ "commands",
797
+ "report",
798
+ "index.js"
799
+ ]
800
+ },
634
801
  "sdk": {
635
802
  "aliases": [],
636
803
  "args": {},
@@ -765,5 +932,5 @@
765
932
  ]
766
933
  }
767
934
  },
768
- "version": "0.0.56-beta.pr58.99"
935
+ "version": "0.0.57-beta.pr59.109"
769
936
  }
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.56-beta.pr58.99",
4
+ "version": "0.0.57-beta.pr59.109",
5
5
  "author": "provishalk",
6
6
  "bin": {
7
7
  "mailmodo": "bin/run.js"