@mailmodo/cli 0.0.56 → 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.
Files changed (48) hide show
  1. package/dist/commands/deploy/index.js +8 -3
  2. package/dist/commands/edit/index.js +4 -1
  3. package/dist/commands/init/index.js +12 -3
  4. package/dist/commands/login/index.js +2 -5
  5. package/dist/commands/preview/index.js +2 -0
  6. package/dist/commands/report/index.d.ts +22 -0
  7. package/dist/commands/report/index.js +123 -0
  8. package/dist/lib/api-client.d.ts +2 -0
  9. package/dist/lib/api-client.js +2 -2
  10. package/dist/lib/base-command.d.ts +38 -10
  11. package/dist/lib/base-command.js +174 -18
  12. package/dist/lib/commands/edit/diff.js +2 -2
  13. package/dist/lib/commands/edit/persist.js +6 -4
  14. package/dist/lib/commands/edit/types.d.ts +1 -0
  15. package/dist/lib/commands/emails/editor.js +1 -1
  16. package/dist/lib/commands/init/analysis.js +5 -1
  17. package/dist/lib/commands/login/output.d.ts +2 -2
  18. package/dist/lib/commands/login/output.js +5 -18
  19. package/dist/lib/commands/preview/types.d.ts +3 -0
  20. package/dist/lib/commands/report/output-entries.d.ts +2 -0
  21. package/dist/lib/commands/report/output-entries.js +59 -0
  22. package/dist/lib/commands/report/output-timeseries.d.ts +2 -0
  23. package/dist/lib/commands/report/output-timeseries.js +28 -0
  24. package/dist/lib/commands/report/output.d.ts +3 -0
  25. package/dist/lib/commands/report/output.js +56 -0
  26. package/dist/lib/commands/report/payload.d.ts +2 -0
  27. package/dist/lib/commands/report/payload.js +49 -0
  28. package/dist/lib/commands/report/prompt.d.ts +2 -0
  29. package/dist/lib/commands/report/prompt.js +82 -0
  30. package/dist/lib/commands/report/types.d.ts +97 -0
  31. package/dist/lib/commands/report/types.js +1 -0
  32. package/dist/lib/config.d.ts +2 -0
  33. package/dist/lib/config.js +19 -10
  34. package/dist/lib/constants.d.ts +4 -2
  35. package/dist/lib/constants.js +5 -5
  36. package/dist/lib/messages.d.ts +18 -0
  37. package/dist/lib/messages.js +38 -0
  38. package/dist/lib/templates/missing-templates.d.ts +15 -1
  39. package/dist/lib/templates/missing-templates.js +34 -22
  40. package/dist/lib/templates/regenerate.d.ts +10 -0
  41. package/dist/lib/templates/regenerate.js +29 -0
  42. package/dist/lib/templates/sync.d.ts +33 -0
  43. package/dist/lib/templates/sync.js +106 -0
  44. package/dist/lib/templates/types.d.ts +3 -0
  45. package/dist/lib/yaml-config.d.ts +1 -0
  46. package/dist/lib/yaml-config.js +8 -0
  47. package/oclif.manifest.json +168 -1
  48. package/package.json +1 -1
@@ -20,12 +20,13 @@ async function persistChanges(ctx, editCtx, updated) {
20
20
  await saveTemplate(editCtx.templateFilename, updated.html);
21
21
  }
22
22
  await ctx.syncYaml();
23
+ await ctx.syncTemplate(editCtx.email.id);
23
24
  }
24
- function logJsonResult(ctx, email, updated, oldSubject) {
25
+ function logJsonResult(ctx, email, updated, oldSubject, oldPreviewText) {
25
26
  ctx.log(JSON.stringify({
26
27
  diff: {
27
- previewText: updated.previewText && updated.previewText !== email.previewText
28
- ? { new: updated.previewText, old: email.previewText }
28
+ previewText: updated.previewText && updated.previewText !== oldPreviewText
29
+ ? { new: updated.previewText, old: oldPreviewText }
29
30
  : undefined,
30
31
  subject: oldSubject === email.subject
31
32
  ? undefined
@@ -51,10 +52,11 @@ async function handleAcceptOutput(ctx, email) {
51
52
  export async function finalizeEdit(ctx, editCtx, opts) {
52
53
  const { flags, updated } = opts;
53
54
  const oldSubject = editCtx.email.subject;
55
+ const oldPreviewText = editCtx.email.previewText;
54
56
  applyEmailChanges(editCtx.email, updated);
55
57
  await persistChanges(ctx, editCtx, updated);
56
58
  if (flags.json) {
57
- logJsonResult(ctx, editCtx.email, updated, oldSubject);
59
+ logJsonResult(ctx, editCtx.email, updated, oldSubject, oldPreviewText);
58
60
  }
59
61
  else if (flags.yes) {
60
62
  ctx.log(`\n Updated ${chalk.green('mailmodo.yaml')}\n`);
@@ -33,5 +33,6 @@ export type EditCtx = {
33
33
  post<T>(path: string, body?: unknown): Promise<ApiResponse<T>>;
34
34
  runCommand(id: string, argv: string[]): Promise<void>;
35
35
  spinner<T>(text: string, json: boolean, work: () => Promise<T>): Promise<T>;
36
+ syncTemplate(emailId: string): Promise<void>;
36
37
  syncYaml(): Promise<void>;
37
38
  };
@@ -27,7 +27,7 @@ export async function openTemplateInEditor(ctx, template) {
27
27
  stdio: 'inherit',
28
28
  });
29
29
  child.on('error', () => resolve(false));
30
- child.on('close', () => resolve(true));
30
+ child.on('close', (code) => resolve(code === 0));
31
31
  });
32
32
  if (launched)
33
33
  return;
@@ -4,8 +4,12 @@ import { API_ENDPOINTS } from '../../constants.js';
4
4
  import { isValidUrl } from '../../utils.js';
5
5
  import { logAnalysisSummary } from './output.js';
6
6
  export async function promptProductUrl(flagUrl) {
7
- if (flagUrl)
7
+ if (flagUrl) {
8
+ if (!isValidUrl(flagUrl)) {
9
+ throw new Error(`Invalid URL: "${flagUrl}". Please enter a valid URL (e.g., https://myapp.com)`);
10
+ }
8
11
  return flagUrl;
12
+ }
9
13
  return input({
10
14
  message: 'What is your product URL?',
11
15
  validate(value) {
@@ -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
  }
@@ -1,7 +1,9 @@
1
1
  import type { ApiResponse } from '../../api-client.js';
2
+ import type { MailmodoYaml } from '../../yaml-config.js';
2
3
  export type PreviewCtx = {
3
4
  error(msg: string): never;
4
5
  exit(code?: number): never;
6
+ fetchTemplate(emailId: string): Promise<boolean>;
5
7
  log(msg?: string): void;
6
8
  onApiError(resp: {
7
9
  error?: string;
@@ -9,6 +11,7 @@ export type PreviewCtx = {
9
11
  }): never;
10
12
  post<T>(path: string, body?: unknown): Promise<ApiResponse<T>>;
11
13
  spinner<T>(text: string, json: boolean, work: () => Promise<T>): Promise<T>;
14
+ syncTemplates(yaml: MailmodoYaml): Promise<void>;
12
15
  syncYaml(): Promise<void>;
13
16
  };
14
17
  export type PreviewEmail = {
@@ -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
  }
@@ -4,11 +4,12 @@
4
4
  * when this is true so end users never see internal request metadata.
5
5
  */
6
6
  export declare const IS_DEV_MODE: boolean;
7
- export declare const API_BASE_URL = "https://app-vertex-debug.azurewebsites.net";
7
+ export declare const API_BASE_URL: string;
8
8
  export declare const API_ENDPOINTS: Readonly<{
9
9
  ANALYTICS: "/analytics";
10
10
  ANALYZE: "/analyze";
11
11
  ASSETS_LOGO: "/assets/logo";
12
+ ASSETS_TEMPLATES: "/assets/templates";
12
13
  ASSETS_YAML: "/assets/yaml";
13
14
  AUTH_VALIDATE: "/auth/validate";
14
15
  BILLING_CAP: "/billing/cap";
@@ -25,12 +26,13 @@ export declare const API_ENDPOINTS: Readonly<{
25
26
  GENERATE: "/email/generate";
26
27
  LOGS: "/logs";
27
28
  PREVIEW: "/preview";
29
+ REPORTS: "/reports";
28
30
  SEQUENCES: "/sequences";
29
31
  SEQUENCES_DEPLOY: "/sequences/deploy";
30
32
  SEQUENCES_SDK: "/sequences/sdk";
31
33
  SEQUENCES_VALIDATE: "/sequences/validate";
32
34
  }>;
33
- export declare const LOGIN_URL = "https://app-vertex-debug.azurewebsites.net/signup.html";
35
+ export declare const LOGIN_URL: string;
34
36
  export declare const PREVIEW_PORT = 3421;
35
37
  export declare const DEFAULT_BRAND_COLOR = "#1A56DB";
36
38
  export declare const TEMPLATES_DIR = "mailmodo";