@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
@@ -1,13 +1,12 @@
1
1
  /** Set by `bin/dev.js` when running the CLI locally (tsx bootstrap). */
2
2
  const DEV_API_BASE_URL = 'https://app-vertex-debug.azurewebsites.net';
3
+ const PRODUCTION_API_BASE_URL = 'https://api.mailmodo.dev';
3
4
  /**
4
5
  * True when the CLI is running in a dev/debug context. Verbose request
5
6
  * diagnostics (URL, status, response body, error code) are only surfaced
6
7
  * when this is true so end users never see internal request metadata.
7
8
  */
8
9
  export const IS_DEV_MODE = Boolean(process.env.MAILMODO_DEV_TSX || process.env.MAILMODO_DEBUG);
9
- // const PRODUCTION_API_BASE_URL = 'https://api.mailmodo.com';
10
- const PRODUCTION_API_BASE_URL = 'https://app-vertex-debug.azurewebsites.net';
11
10
  export const API_BASE_URL = process.env.MAILMODO_DEV_TSX
12
11
  ? DEV_API_BASE_URL
13
12
  : PRODUCTION_API_BASE_URL;
@@ -15,6 +14,7 @@ export const API_ENDPOINTS = Object.freeze({
15
14
  ANALYTICS: '/analytics',
16
15
  ANALYZE: '/analyze',
17
16
  ASSETS_LOGO: '/assets/logo',
17
+ ASSETS_TEMPLATES: '/assets/templates',
18
18
  ASSETS_YAML: '/assets/yaml',
19
19
  AUTH_VALIDATE: '/auth/validate',
20
20
  BILLING_CAP: '/billing/cap',
@@ -31,14 +31,14 @@ 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',
37
38
  SEQUENCES_VALIDATE: '/sequences/validate',
38
39
  });
39
- const DEV_LOGIN_URL = 'https://app-vertex-debug.azurewebsites.net/signup.html';
40
- // const PRODUCTION_LOGIN_URL = 'https://mailmodo.com/cli';
41
- const PRODUCTION_LOGIN_URL = 'https://app-vertex-debug.azurewebsites.net/signup.html';
40
+ const DEV_LOGIN_URL = 'https://app-vertex-debug.azurewebsites.net/signup';
41
+ const PRODUCTION_LOGIN_URL = 'https://app.mailmodo.dev/signup';
42
42
  export const LOGIN_URL = process.env.MAILMODO_DEV_TSX
43
43
  ? DEV_LOGIN_URL
44
44
  : PRODUCTION_LOGIN_URL;
@@ -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:";
@@ -63,6 +80,7 @@ export declare const DEPLOY: {
63
80
  readonly SDK_ONBOARDING_HEADER: string;
64
81
  readonly SUCCESS: `${string} Emails are live.`;
65
82
  };
83
+ export declare function restoredFromServerHint(ids: string[]): string;
66
84
  export declare function pauseSuccess(sequenceId: string): string;
67
85
  export declare function pauseAlready(sequenceId: string): string;
68
86
  export declare function resumeSuccess(sequenceId: string): string;
@@ -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:',
@@ -63,6 +80,27 @@ export const DEPLOY = {
63
80
  SDK_ONBOARDING_HEADER: chalk.bold('ADD THIS TO YOUR APP (one-time only):'),
64
81
  SUCCESS: `${chalk.green('Deployed.')} Emails are live.`,
65
82
  };
83
+ export function restoredFromServerHint(ids) {
84
+ if (ids.length === 1) {
85
+ const [id] = ids;
86
+ return (`Template ${chalk.cyan(id)} was not found locally and has been refreshed from the server.\n` +
87
+ ` Review it with ${chalk.cyan(`mailmodo preview ${id}`)}, then run ${chalk.cyan('mailmodo deploy')} again.`);
88
+ }
89
+ const header = 'Template ID';
90
+ const colWidth = Math.max(header.length, ...ids.map((id) => id.length));
91
+ const pad = (s) => s.padEnd(colWidth);
92
+ const hr = '─'.repeat(colWidth + 2);
93
+ const table = [
94
+ ` ┌${hr}┐`,
95
+ ` │ ${chalk.bold(pad(header))} │`,
96
+ ` ├${hr}┤`,
97
+ ...ids.map((id) => ` │ ${chalk.cyan(pad(id))} │`),
98
+ ` └${hr}┘`,
99
+ ].join('\n');
100
+ return (`${ids.length} templates were not found locally and have been refreshed from the server:\n\n` +
101
+ `${table}\n\n` +
102
+ ` Review each with ${chalk.cyan('mailmodo preview <id>')}, then run ${chalk.cyan('mailmodo deploy')} again.`);
103
+ }
66
104
  export function pauseSuccess(sequenceId) {
67
105
  return `Sequence ${chalk.cyan(sequenceId)} paused. Run ${chalk.cyan(`mailmodo deploy --resume ${sequenceId}`)} to resume.`;
68
106
  }
@@ -1,5 +1,19 @@
1
1
  import { type MailmodoYaml } from '../yaml-config.js';
2
2
  import type { DeployFlags } from '../commands/deploy/types.js';
3
3
  import type { RegenCtx } from './types.js';
4
+ /**
5
+ * Returns the IDs of emails whose template file does not exist on disk.
6
+ * Resolves the correct filename for each email by accounting for per-email
7
+ * and project-level style settings (branded vs. plain).
8
+ */
4
9
  export declare function getMissingTemplateIds(yamlConfig: MailmodoYaml): string[];
5
- export declare function handleMissingTemplates(ctx: RegenCtx, yamlConfig: MailmodoYaml, missingIds: string[], flags: DeployFlags): Promise<boolean>;
10
+ /**
11
+ * Handles the case where one or more template files are missing before a
12
+ * command can proceed. First attempts a silent server restore for all missing
13
+ * IDs; if the server has backups for all of them the command continues without
14
+ * any user interaction. Only falls through to an interactive prompt (or an
15
+ * immediate error in --json / --yes mode) for files the server also does not have.
16
+ * Returns true if the situation was resolved (restored or regenerated), false
17
+ * if the user chose to abort.
18
+ */
19
+ export declare function handleMissingTemplates(ctx: RegenCtx, yamlConfig: MailmodoYaml, missingIds: string[], flags: DeployFlags): Promise<'regenerated' | 'restored' | false>;
@@ -2,44 +2,56 @@ import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { select } from '@inquirer/prompts';
4
4
  import chalk from 'chalk';
5
- import { API_ENDPOINTS, TEMPLATES_DIR } from '../constants.js';
5
+ import { TEMPLATES_DIR } from '../constants.js';
6
6
  import { MISSING_TEMPLATES } from '../messages.js';
7
- import { getTemplateFilename, saveTemplate, } from '../yaml-config.js';
8
- import { buildRegeneratePayload } from '../commands/deploy/payload.js';
7
+ import { getTemplateFilename } from '../yaml-config.js';
8
+ import { regenerateMissingTemplates } from './regenerate.js';
9
+ /**
10
+ * Returns the IDs of emails whose template file does not exist on disk.
11
+ * Resolves the correct filename for each email by accounting for per-email
12
+ * and project-level style settings (branded vs. plain).
13
+ */
9
14
  export function getMissingTemplateIds(yamlConfig) {
10
15
  return yamlConfig.emails
11
16
  .filter((e) => !existsSync(join(process.cwd(), TEMPLATES_DIR, getTemplateFilename(e.id, e.style, yamlConfig.project?.emailStyle))))
12
17
  .map((e) => e.id);
13
18
  }
14
- async function regenerateMissingTemplates(ctx, yamlConfig, missingIds, flags) {
15
- const response = await ctx.spinner(` ${MISSING_TEMPLATES.REGENERATE_SPINNER}`, flags.json, () => ctx.post(API_ENDPOINTS.GENERATE, buildRegeneratePayload(yamlConfig, missingIds)));
16
- if (!response.ok)
17
- ctx.onApiError(response);
18
- const saves = [];
19
- for (const email of response.data?.emails ?? []) {
20
- if (!/^[\w-]+$/.test(email.id))
21
- continue;
22
- if (email.html)
23
- saves.push(saveTemplate(`${email.id}.html`, email.html));
24
- if (email.plainHtml)
25
- saves.push(saveTemplate(`${email.id}_plain.html`, email.plainHtml));
26
- }
27
- await Promise.all(saves);
28
- await ctx.syncYaml();
19
+ /**
20
+ * Attempts to restore each missing template from the server without showing
21
+ * any prompt or message to the user. Returns the IDs that could not be
22
+ * restored — either because the server has no backup or a network error occurred.
23
+ */
24
+ async function silentlyRestoreFromServer(ctx, missingIds) {
25
+ const results = await Promise.all(missingIds.map((id) => ctx.fetchTemplate(id)));
26
+ // Keep only the IDs where the server fetch returned false (not restored)
27
+ return missingIds.filter((_, i) => !results[i]);
29
28
  }
29
+ /**
30
+ * Handles the case where one or more template files are missing before a
31
+ * command can proceed. First attempts a silent server restore for all missing
32
+ * IDs; if the server has backups for all of them the command continues without
33
+ * any user interaction. Only falls through to an interactive prompt (or an
34
+ * immediate error in --json / --yes mode) for files the server also does not have.
35
+ * Returns true if the situation was resolved (restored or regenerated), false
36
+ * if the user chose to abort.
37
+ */
30
38
  export async function handleMissingTemplates(ctx, yamlConfig, missingIds, flags) {
39
+ // Try to silently recover from server before interrupting the user
40
+ const stillMissing = await silentlyRestoreFromServer(ctx, missingIds);
41
+ if (stillMissing.length === 0)
42
+ return 'restored';
31
43
  if (flags.json) {
32
44
  ctx.log(JSON.stringify({
33
45
  error: 'missing_templates',
34
46
  message: MISSING_TEMPLATES.YES_ERROR,
35
- missingTemplates: missingIds.map((id) => `${id}.html`),
47
+ missingTemplates: stillMissing.map((id) => `${id}.html`),
36
48
  }, null, 2));
37
49
  ctx.exit(1);
38
50
  }
39
51
  if (flags.yes)
40
52
  ctx.error(MISSING_TEMPLATES.YES_ERROR);
41
53
  ctx.log(`\n ${MISSING_TEMPLATES.HEADER}`);
42
- for (const id of missingIds)
54
+ for (const id of stillMissing)
43
55
  ctx.log(` ${chalk.red('✗')} mailmodo/${id}.html`);
44
56
  ctx.log(`\n ${MISSING_TEMPLATES.REGENERATE_NOTE}\n`);
45
57
  const action = await select({
@@ -56,6 +68,6 @@ export async function handleMissingTemplates(ctx, yamlConfig, missingIds, flags)
56
68
  ctx.log(`\n ${MISSING_TEMPLATES.ABORT_HINT}\n`);
57
69
  return false;
58
70
  }
59
- await regenerateMissingTemplates(ctx, yamlConfig, missingIds, flags);
60
- return true;
71
+ await regenerateMissingTemplates(ctx, yamlConfig, stillMissing, flags);
72
+ return 'regenerated';
61
73
  }
@@ -0,0 +1,10 @@
1
+ import { type MailmodoYaml } from '../yaml-config.js';
2
+ import type { DeployFlags } from '../commands/deploy/types.js';
3
+ import type { RegenCtx } from './types.js';
4
+ /**
5
+ * Calls POST /email/generate with the missing email IDs, writes the returned
6
+ * HTML files to disk, then syncs both the YAML and all templates to the server.
7
+ * Called when the user chooses "Re-generate via AI" from the missing-template
8
+ * prompt, or when no server backup exists to restore from.
9
+ */
10
+ export declare function regenerateMissingTemplates(ctx: RegenCtx, yamlConfig: MailmodoYaml, missingIds: string[], flags: DeployFlags): Promise<void>;
@@ -0,0 +1,29 @@
1
+ import { API_ENDPOINTS } from '../constants.js';
2
+ import { saveTemplate } from '../yaml-config.js';
3
+ import { buildRegeneratePayload } from '../commands/deploy/payload.js';
4
+ import { MISSING_TEMPLATES } from '../messages.js';
5
+ /**
6
+ * Calls POST /email/generate with the missing email IDs, writes the returned
7
+ * HTML files to disk, then syncs both the YAML and all templates to the server.
8
+ * Called when the user chooses "Re-generate via AI" from the missing-template
9
+ * prompt, or when no server backup exists to restore from.
10
+ */
11
+ export async function regenerateMissingTemplates(ctx, yamlConfig, missingIds, flags) {
12
+ const response = await ctx.spinner(` ${MISSING_TEMPLATES.REGENERATE_SPINNER}`, flags.json, () => ctx.post(API_ENDPOINTS.GENERATE, buildRegeneratePayload(yamlConfig, missingIds)));
13
+ if (!response.ok)
14
+ ctx.onApiError(response);
15
+ const saves = [];
16
+ for (const email of response.data?.emails ?? []) {
17
+ // Guard against path traversal: only allow alphanumeric, dash, and underscore IDs
18
+ if (!/^[\w-]+$/.test(email.id))
19
+ continue;
20
+ if (email.html)
21
+ saves.push(saveTemplate(`${email.id}.html`, email.html));
22
+ if (email.plainHtml)
23
+ saves.push(saveTemplate(`${email.id}_plain.html`, email.plainHtml));
24
+ }
25
+ await Promise.all(saves);
26
+ await ctx.syncYaml();
27
+ // Back up the freshly generated templates so the server reflects the new state
28
+ await ctx.syncTemplates(yamlConfig);
29
+ }
@@ -0,0 +1,33 @@
1
+ import type { ApiClient } from '../api-client.js';
2
+ import { type MailmodoYaml } from '../yaml-config.js';
3
+ /**
4
+ * Bulk-uploads all template HTML files referenced in the YAML to the server
5
+ * as a backup. Only files present on disk are sent; any extra files in the
6
+ * mailmodo/ folder that are not listed in the YAML are intentionally skipped.
7
+ * All files are read in parallel before building the multipart payload.
8
+ */
9
+ export declare function syncTemplatesToServer(client: ApiClient, yaml: MailmodoYaml): Promise<void>;
10
+ /**
11
+ * Uploads a single template's HTML files to the server for incremental sync.
12
+ * Used by the edit command after a change so only the modified template is
13
+ * re-uploaded instead of the full set. The branded HTML (`html` field) is
14
+ * required by the API; the plain variant (`plainHtml`) is uploaded only if
15
+ * its file exists on disk.
16
+ */
17
+ export declare function syncTemplateToServer(client: ApiClient, emailId: string): Promise<void>;
18
+ /**
19
+ * Downloads all backed-up templates from the server and writes them to the
20
+ * local mailmodo/ folder. Only templates whose emailId appears in the YAML
21
+ * are written to disk — extra templates stored on the server (e.g. from a
22
+ * previous project state) are intentionally ignored to keep the local folder
23
+ * in sync with the current YAML.
24
+ * Returns true if at least one file was written, false otherwise.
25
+ */
26
+ export declare function fetchTemplatesFromServer(client: ApiClient, yaml: MailmodoYaml): Promise<boolean>;
27
+ /**
28
+ * Downloads a single backed-up template from the server by emailId and writes
29
+ * it to the local mailmodo/ folder. Returns true if the template was found and
30
+ * written successfully, false if it has not been backed up or on any error.
31
+ * The plain HTML variant is only written if the server returned a non-empty value.
32
+ */
33
+ export declare function fetchTemplateFromServer(client: ApiClient, emailId: string): Promise<boolean>;
@@ -0,0 +1,106 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { API_ENDPOINTS, TEMPLATES_DIR } from '../constants.js';
5
+ import { saveTemplate } from '../yaml-config.js';
6
+ /**
7
+ * Bulk-uploads all template HTML files referenced in the YAML to the server
8
+ * as a backup. Only files present on disk are sent; any extra files in the
9
+ * mailmodo/ folder that are not listed in the YAML are intentionally skipped.
10
+ * All files are read in parallel before building the multipart payload.
11
+ */
12
+ export async function syncTemplatesToServer(client, yaml) {
13
+ // Collect filenames for both branded and plain variants, filtered to those that exist on disk
14
+ const filenames = yaml.emails
15
+ .flatMap((email) => [`${email.id}.html`, `${email.id}_plain.html`])
16
+ .filter((f) => existsSync(join(process.cwd(), TEMPLATES_DIR, f)));
17
+ if (filenames.length === 0)
18
+ return;
19
+ // Read all files in parallel to avoid sequential await-in-loop
20
+ const files = await Promise.all(filenames.map(async (f) => ({
21
+ content: await readFile(join(process.cwd(), TEMPLATES_DIR, f), 'utf8'),
22
+ name: f,
23
+ })));
24
+ // API expects multipart fields keyed by the template filename (e.g. abc123.html)
25
+ const formData = new FormData();
26
+ for (const { name, content } of files) {
27
+ formData.append(name, new Blob([content], { type: 'text/html' }), name);
28
+ }
29
+ await client.postFormData(API_ENDPOINTS.ASSETS_TEMPLATES, formData);
30
+ }
31
+ /**
32
+ * Uploads a single template's HTML files to the server for incremental sync.
33
+ * Used by the edit command after a change so only the modified template is
34
+ * re-uploaded instead of the full set. The branded HTML (`html` field) is
35
+ * required by the API; the plain variant (`plainHtml`) is uploaded only if
36
+ * its file exists on disk.
37
+ */
38
+ export async function syncTemplateToServer(client, emailId) {
39
+ const htmlPath = join(process.cwd(), TEMPLATES_DIR, `${emailId}.html`);
40
+ // API requires the branded HTML field; skip entirely if the file is absent
41
+ if (!existsSync(htmlPath))
42
+ return;
43
+ const formData = new FormData();
44
+ const html = await readFile(htmlPath, 'utf8');
45
+ formData.append('html', new Blob([html], { type: 'text/html' }), `${emailId}.html`);
46
+ const plainPath = join(process.cwd(), TEMPLATES_DIR, `${emailId}_plain.html`);
47
+ if (existsSync(plainPath)) {
48
+ const plainHtml = await readFile(plainPath, 'utf8');
49
+ formData.append('plainHtml', new Blob([plainHtml], { type: 'text/html' }), `${emailId}_plain.html`);
50
+ }
51
+ await client.postFormData(`${API_ENDPOINTS.ASSETS_TEMPLATES}/${emailId}`, formData);
52
+ }
53
+ /**
54
+ * Downloads all backed-up templates from the server and writes them to the
55
+ * local mailmodo/ folder. Only templates whose emailId appears in the YAML
56
+ * are written to disk — extra templates stored on the server (e.g. from a
57
+ * previous project state) are intentionally ignored to keep the local folder
58
+ * in sync with the current YAML.
59
+ * Returns true if at least one file was written, false otherwise.
60
+ */
61
+ export async function fetchTemplatesFromServer(client, yaml) {
62
+ try {
63
+ const resp = await client.get(API_ENDPOINTS.ASSETS_TEMPLATES);
64
+ if (!resp.ok || !resp.data?.templates?.length)
65
+ return false;
66
+ // Build a set of valid IDs from the YAML to filter out stale server entries
67
+ const validIds = new Set(yaml.emails.map((e) => e.id));
68
+ const saves = [];
69
+ for (const t of resp.data.templates) {
70
+ if (!validIds.has(t.emailId))
71
+ continue;
72
+ // The API returns an empty string when a variant was never stored; skip those
73
+ if (t.html)
74
+ saves.push(saveTemplate(`${t.emailId}.html`, t.html));
75
+ if (t.plainHtml)
76
+ saves.push(saveTemplate(`${t.emailId}_plain.html`, t.plainHtml));
77
+ }
78
+ await Promise.all(saves);
79
+ return saves.length > 0;
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ }
85
+ /**
86
+ * Downloads a single backed-up template from the server by emailId and writes
87
+ * it to the local mailmodo/ folder. Returns true if the template was found and
88
+ * written successfully, false if it has not been backed up or on any error.
89
+ * The plain HTML variant is only written if the server returned a non-empty value.
90
+ */
91
+ export async function fetchTemplateFromServer(client, emailId) {
92
+ try {
93
+ const resp = await client.get(`${API_ENDPOINTS.ASSETS_TEMPLATES}/${emailId}`);
94
+ // A missing or empty html field means the template was never backed up
95
+ if (!resp.ok || !resp.data?.html)
96
+ return false;
97
+ await saveTemplate(`${emailId}.html`, resp.data.html);
98
+ // plainHtml is empty string when no plain variant is stored; skip in that case
99
+ if (resp.data.plainHtml)
100
+ await saveTemplate(`${emailId}_plain.html`, resp.data.plainHtml);
101
+ return true;
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ }
@@ -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 RegenCtx = {
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,5 +11,6 @@ export type RegenCtx = {
9
11
  }): never;
10
12
  post<T = Record<string, unknown>>(path: string, body?: Record<string, unknown> | 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
  };
@@ -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"
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",
4
+ "version": "0.0.57-beta.pr59.109",
5
5
  "author": "provishalk",
6
6
  "bin": {
7
7
  "mailmodo": "bin/run.js"