@mailmodo/cli 0.0.56-beta.pr58.100 → 0.0.56-beta.pr58.102

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,10 +20,12 @@ 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;
23
24
  if (this.isBlankDirectory()) {
24
- const restored = await this.promptInitServerRestore(baseFlags);
25
- if (restored)
25
+ const result = await this.promptInitServerRestore(baseFlags);
26
+ if (result.restored)
26
27
  return;
28
+ serverYaml = result.serverYaml;
27
29
  }
28
30
  const existing = await loadYaml();
29
31
  if (!(await confirmOverwrite(ctx, baseFlags, existing)))
@@ -38,8 +40,9 @@ export default class Init extends BaseCommand {
38
40
  const generatedEmails = generateResponse.data?.emails || [];
39
41
  const emailConfigs = buildEmailConfigs(analysisPayload, generatedEmails);
40
42
  const yamlConfig = buildYamlConfig(analysisPayload, emailConfigs, productUrl);
41
- if (existing)
42
- preserveUserFields(yamlConfig, existing);
43
+ const fieldSource = existing ?? serverYaml;
44
+ if (fieldSource)
45
+ preserveUserFields(yamlConfig, fieldSource);
43
46
  await saveYaml(yamlConfig);
44
47
  await applyMonthlyCap(ctx, yamlConfig);
45
48
  await saveAllTemplates(analysisPayload.recommendedEmails, generatedEmails);
@@ -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
+ }
@@ -74,7 +74,10 @@ export declare abstract class BaseCommand extends Command {
74
74
  protected promptInitServerRestore(flags: {
75
75
  json?: boolean;
76
76
  yes?: boolean;
77
- }): Promise<boolean>;
77
+ }): Promise<{
78
+ restored: boolean;
79
+ serverYaml: MailmodoYaml | null;
80
+ }>;
78
81
  private fetchAndWriteYaml;
79
82
  /**
80
83
  * Attempts to fetch mailmodo.yaml from the server and save it locally.
@@ -11,7 +11,7 @@ import { API_ENDPOINTS, IS_DEV_MODE, TEMPLATES_DIR, YAML_FILE, } from './constan
11
11
  import { syncTemplatesToServer, syncTemplateToServer, fetchTemplatesFromServer, fetchTemplateFromServer, } from './templates/sync.js';
12
12
  import { getMissingTemplateIds } from './templates/missing-templates.js';
13
13
  import { BLANK_DIR, ERRORS, INFO, PROMPTS, quotaExhaustedMessage, recordLabel, VALIDATION, } from './messages.js';
14
- import { loadYaml, saveYaml } from './yaml-config.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.
@@ -151,7 +151,7 @@ export class BaseCommand extends Command {
151
151
  async promptInitServerRestore(flags) {
152
152
  const yamlText = await this.fetchYamlText();
153
153
  if (!yamlText)
154
- return false;
154
+ return { restored: false, serverYaml: null };
155
155
  let shouldRestore = Boolean(flags.yes || flags.json);
156
156
  if (!shouldRestore) {
157
157
  const choice = await select({
@@ -163,17 +163,18 @@ export class BaseCommand extends Command {
163
163
  });
164
164
  shouldRestore = choice === 'restore';
165
165
  }
166
- if (!shouldRestore)
167
- return false;
166
+ if (!shouldRestore) {
167
+ return { restored: false, serverYaml: parseYamlText(yamlText) };
168
+ }
168
169
  await writeFile(join(process.cwd(), YAML_FILE), yamlText);
169
170
  const yaml = await loadYaml();
170
171
  if (!yaml)
171
- return false;
172
+ return { restored: false, serverYaml: null };
172
173
  const client = this.apiClient;
173
174
  if (client)
174
175
  await this.fetchMissingTemplates(client, yaml);
175
176
  this.log(`\n ${BLANK_DIR.RESTORED_INIT}\n`);
176
- return true;
177
+ return { restored: true, serverYaml: yaml };
177
178
  }
178
179
  async fetchAndWriteYaml(client) {
179
180
  try {
@@ -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,48 @@
1
+ import chalk from 'chalk';
2
+ function statusColor(status) {
3
+ switch (status) {
4
+ case 'bounced':
5
+ case 'complained': {
6
+ return chalk.red;
7
+ }
8
+ case 'clicked':
9
+ case 'delivered':
10
+ case 'opened':
11
+ case 'sent': {
12
+ return chalk.green;
13
+ }
14
+ case 'skipped': {
15
+ return chalk.yellow;
16
+ }
17
+ default: {
18
+ return chalk.white;
19
+ }
20
+ }
21
+ }
22
+ export function renderEntries(ctx, data) {
23
+ const { entries, limit, page, total } = data;
24
+ ctx.log(`\n ${'Time'.padEnd(22)}${'Email'.padEnd(24)}${'Sequence'.padEnd(20)}${'Status'.padEnd(12)}Contact`);
25
+ ctx.log(` ${'─'.repeat(90)}`);
26
+ if (!entries?.length) {
27
+ ctx.log(` ${chalk.dim('No entries found.')}`);
28
+ ctx.log('');
29
+ return;
30
+ }
31
+ for (const entry of entries) {
32
+ const time = (entry.timestamp || '').padEnd(22);
33
+ const emailId = (entry.emailId || '').padEnd(24);
34
+ const seqId = (entry.sequenceId || '').padEnd(20);
35
+ const colorFn = statusColor(entry.status);
36
+ const status = colorFn((entry.status || '').padEnd(12));
37
+ ctx.log(` ${time}${emailId}${seqId}${status}${entry.contact || ''}`);
38
+ if (entry.reason) {
39
+ const label = entry.status === 'skipped' ? 'condition not met' : 'reason';
40
+ ctx.log(` ${' '.repeat(78)}${chalk.dim(`(${label}: ${entry.reason})`)}`);
41
+ }
42
+ }
43
+ const totalPages = Math.ceil(total / limit);
44
+ ctx.log(`\n Page ${page} of ${totalPages} · ${total} total entries`);
45
+ if (page < totalPages)
46
+ ctx.log(` ${chalk.dim(`Next: --page ${page + 1}`)}`);
47
+ ctx.log('');
48
+ }
@@ -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,80 @@
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 ID', 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 = await select({
75
+ choices: GROUP_BY_CHOICES,
76
+ default: flags['group-by'],
77
+ message: 'Group by:',
78
+ });
79
+ return { ...flags, ...timeRange, 'group-by': groupBy, output };
80
+ }
@@ -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 {};
@@ -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',
@@ -14,7 +14,7 @@ export declare const PROMPTS: {
14
14
  readonly SENDER_EMAIL: "Sender email address:";
15
15
  };
16
16
  export declare const BLANK_DIR: {
17
- readonly CHOICE_FRESH: "Start fresh — create a new project here";
17
+ readonly CHOICE_FRESH: "Start fresh — new emails will be generated (your domain, sender, and address settings will be kept)";
18
18
  readonly CHOICE_RESTORE: "Restore project files from server";
19
19
  readonly CHOICE_RESTORE_INIT: "Restore existing project files from server";
20
20
  readonly CHOICE_SKIP: "Skip — I'll navigate to my project directory manually";
@@ -64,6 +64,13 @@ export declare const INFO: {
64
64
  readonly YAML_RESTORED_FROM_SERVER: string;
65
65
  readonly YAML_RESTORED_ON_LOGIN: ` mailmodo.yaml restored from server. Run ${string} to re-deploy your sequences.`;
66
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
+ };
67
74
  export declare const DEPLOY: {
68
75
  readonly CHANGES_HEADER: "Changes vs. last deployment:";
69
76
  readonly DEPLOYING_HEADER: "Deploying:";
@@ -15,7 +15,7 @@ export const PROMPTS = {
15
15
  SENDER_EMAIL: 'Sender email address:',
16
16
  };
17
17
  export const BLANK_DIR = {
18
- CHOICE_FRESH: 'Start fresh — create a new project here',
18
+ CHOICE_FRESH: 'Start fresh — new emails will be generated (your domain, sender, and address settings will be kept)',
19
19
  CHOICE_RESTORE: 'Restore project files from server',
20
20
  CHOICE_RESTORE_INIT: 'Restore existing project files from server',
21
21
  CHOICE_SKIP: "Skip — I'll navigate to my project directory manually",
@@ -64,6 +64,13 @@ export const INFO = {
64
64
  YAML_RESTORED_FROM_SERVER: chalk.dim(' mailmodo.yaml not found locally — restored from server.'),
65
65
  YAML_RESTORED_ON_LOGIN: ` mailmodo.yaml restored from server. Run ${chalk.cyan("'mailmodo deploy'")} to re-deploy your sequences.`,
66
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
+ };
67
74
  export const DEPLOY = {
68
75
  CHANGES_HEADER: 'Changes vs. last deployment:',
69
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
  *
@@ -426,13 +426,12 @@
426
426
  "index.js"
427
427
  ]
428
428
  },
429
- "login": {
429
+ "logout": {
430
430
  "aliases": [],
431
431
  "args": {},
432
- "description": "Authenticate with Mailmodo using your API key",
432
+ "description": "Sign out by removing saved credentials from this machine",
433
433
  "examples": [
434
- "<%= config.bin %> login",
435
- "MAILMODO_API_KEY=YOUR_API_KEY <%= config.bin %> login"
434
+ "<%= config.bin %> logout"
436
435
  ],
437
436
  "flags": {
438
437
  "json": {
@@ -451,7 +450,7 @@
451
450
  },
452
451
  "hasDynamicHelp": false,
453
452
  "hiddenAliases": [],
454
- "id": "login",
453
+ "id": "logout",
455
454
  "pluginAlias": "@mailmodo/cli",
456
455
  "pluginName": "@mailmodo/cli",
457
456
  "pluginType": "core",
@@ -461,16 +460,17 @@
461
460
  "relativePath": [
462
461
  "dist",
463
462
  "commands",
464
- "login",
463
+ "logout",
465
464
  "index.js"
466
465
  ]
467
466
  },
468
- "logout": {
467
+ "login": {
469
468
  "aliases": [],
470
469
  "args": {},
471
- "description": "Sign out by removing saved credentials from this machine",
470
+ "description": "Authenticate with Mailmodo using your API key",
472
471
  "examples": [
473
- "<%= config.bin %> logout"
472
+ "<%= config.bin %> login",
473
+ "MAILMODO_API_KEY=YOUR_API_KEY <%= config.bin %> login"
474
474
  ],
475
475
  "flags": {
476
476
  "json": {
@@ -489,7 +489,7 @@
489
489
  },
490
490
  "hasDynamicHelp": false,
491
491
  "hiddenAliases": [],
492
- "id": "logout",
492
+ "id": "login",
493
493
  "pluginAlias": "@mailmodo/cli",
494
494
  "pluginName": "@mailmodo/cli",
495
495
  "pluginType": "core",
@@ -499,7 +499,7 @@
499
499
  "relativePath": [
500
500
  "dist",
501
501
  "commands",
502
- "logout",
502
+ "login",
503
503
  "index.js"
504
504
  ]
505
505
  },
@@ -631,14 +631,17 @@
631
631
  "index.js"
632
632
  ]
633
633
  },
634
- "sdk": {
634
+ "report": {
635
635
  "aliases": [],
636
636
  "args": {},
637
- "description": "Show the SDK track() / identify() reference for deployed sequences",
637
+ "description": "Fetch an email analytics report",
638
638
  "examples": [
639
- "<%= config.bin %> sdk",
640
- "<%= config.bin %> sdk --sequence-id a1b2c3d4",
641
- "<%= config.bin %> sdk --json"
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"
642
645
  ],
643
646
  "flags": {
644
647
  "json": {
@@ -654,9 +657,126 @@
654
657
  "allowNo": false,
655
658
  "type": "boolean"
656
659
  },
657
- "sequence-id": {
658
- "description": "Limit output to a single active sequence by ID (default: all active sequences)",
659
- "name": "sequence-id",
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",
660
780
  "hasDynamicHelp": false,
661
781
  "multiple": false,
662
782
  "type": "option"
@@ -664,7 +784,7 @@
664
784
  },
665
785
  "hasDynamicHelp": false,
666
786
  "hiddenAliases": [],
667
- "id": "sdk",
787
+ "id": "report",
668
788
  "pluginAlias": "@mailmodo/cli",
669
789
  "pluginName": "@mailmodo/cli",
670
790
  "pluginType": "core",
@@ -674,18 +794,18 @@
674
794
  "relativePath": [
675
795
  "dist",
676
796
  "commands",
677
- "sdk",
797
+ "report",
678
798
  "index.js"
679
799
  ]
680
800
  },
681
- "settings": {
801
+ "sdk": {
682
802
  "aliases": [],
683
803
  "args": {},
684
- "description": "View and update project settings",
804
+ "description": "Show the SDK track() / identify() reference for deployed sequences",
685
805
  "examples": [
686
- "<%= config.bin %> settings",
687
- "<%= config.bin %> settings --set brand_color=#0F3460",
688
- "<%= config.bin %> settings --json"
806
+ "<%= config.bin %> sdk",
807
+ "<%= config.bin %> sdk --sequence-id a1b2c3d4",
808
+ "<%= config.bin %> sdk --json"
689
809
  ],
690
810
  "flags": {
691
811
  "json": {
@@ -701,9 +821,9 @@
701
821
  "allowNo": false,
702
822
  "type": "boolean"
703
823
  },
704
- "set": {
705
- "description": "Set a setting (format: key=value)",
706
- "name": "set",
824
+ "sequence-id": {
825
+ "description": "Limit output to a single active sequence by ID (default: all active sequences)",
826
+ "name": "sequence-id",
707
827
  "hasDynamicHelp": false,
708
828
  "multiple": false,
709
829
  "type": "option"
@@ -711,7 +831,7 @@
711
831
  },
712
832
  "hasDynamicHelp": false,
713
833
  "hiddenAliases": [],
714
- "id": "settings",
834
+ "id": "sdk",
715
835
  "pluginAlias": "@mailmodo/cli",
716
836
  "pluginName": "@mailmodo/cli",
717
837
  "pluginType": "core",
@@ -721,7 +841,7 @@
721
841
  "relativePath": [
722
842
  "dist",
723
843
  "commands",
724
- "settings",
844
+ "sdk",
725
845
  "index.js"
726
846
  ]
727
847
  },
@@ -763,7 +883,54 @@
763
883
  "status",
764
884
  "index.js"
765
885
  ]
886
+ },
887
+ "settings": {
888
+ "aliases": [],
889
+ "args": {},
890
+ "description": "View and update project settings",
891
+ "examples": [
892
+ "<%= config.bin %> settings",
893
+ "<%= config.bin %> settings --set brand_color=#0F3460",
894
+ "<%= config.bin %> settings --json"
895
+ ],
896
+ "flags": {
897
+ "json": {
898
+ "description": "Output as JSON",
899
+ "name": "json",
900
+ "allowNo": false,
901
+ "type": "boolean"
902
+ },
903
+ "yes": {
904
+ "char": "y",
905
+ "description": "Skip confirmation prompts",
906
+ "name": "yes",
907
+ "allowNo": false,
908
+ "type": "boolean"
909
+ },
910
+ "set": {
911
+ "description": "Set a setting (format: key=value)",
912
+ "name": "set",
913
+ "hasDynamicHelp": false,
914
+ "multiple": false,
915
+ "type": "option"
916
+ }
917
+ },
918
+ "hasDynamicHelp": false,
919
+ "hiddenAliases": [],
920
+ "id": "settings",
921
+ "pluginAlias": "@mailmodo/cli",
922
+ "pluginName": "@mailmodo/cli",
923
+ "pluginType": "core",
924
+ "strict": true,
925
+ "enableJsonFlag": false,
926
+ "isESM": true,
927
+ "relativePath": [
928
+ "dist",
929
+ "commands",
930
+ "settings",
931
+ "index.js"
932
+ ]
766
933
  }
767
934
  },
768
- "version": "0.0.56-beta.pr58.100"
935
+ "version": "0.0.56-beta.pr58.102"
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.100",
4
+ "version": "0.0.56-beta.pr58.102",
5
5
  "author": "provishalk",
6
6
  "bin": {
7
7
  "mailmodo": "bin/run.js"