@mailmodo/cli 0.0.55-beta.pr57.93 → 0.0.56-beta.pr58.100

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.
@@ -3,7 +3,7 @@ import { confirm } from '@inquirer/prompts';
3
3
  import chalk from 'chalk';
4
4
  import { BaseCommand } from '../../lib/base-command.js';
5
5
  import { API_ENDPOINTS } from '../../lib/constants.js';
6
- import { MISSING_TEMPLATES } from '../../lib/messages.js';
6
+ import { MISSING_TEMPLATES, restoredFromServerHint, } from '../../lib/messages.js';
7
7
  import { buildDeployPayload } from '../../lib/commands/deploy/payload.js';
8
8
  import { logDeploySuccessInstructions, logPreDeploySummary, } from '../../lib/commands/deploy/output.js';
9
9
  import { pauseSequence, resumeSequence, } from '../../lib/commands/deploy/sequence-status.js';
@@ -44,8 +44,10 @@ export default class Deploy extends BaseCommand {
44
44
  const yamlConfig = await this.ensureYaml();
45
45
  const missingIds = getMissingTemplateIds(yamlConfig);
46
46
  if (missingIds.length > 0) {
47
- const regenerated = await handleMissingTemplates(ctx, yamlConfig, missingIds, baseFlags);
48
- if (regenerated)
47
+ const result = await handleMissingTemplates(ctx, yamlConfig, missingIds, baseFlags);
48
+ if (result === 'restored')
49
+ ctx.log(`\n ${chalk.green('✓')} ${restoredFromServerHint(missingIds)}\n`);
50
+ else if (result === 'regenerated')
49
51
  ctx.log(`\n ${chalk.green('✓')} ${MISSING_TEMPLATES.REVIEW_HINT}\n`);
50
52
  return;
51
53
  }
@@ -69,6 +71,7 @@ export default class Deploy extends BaseCommand {
69
71
  if (!response.ok)
70
72
  ctx.onApiError(response);
71
73
  await ctx.syncYaml();
74
+ await ctx.syncTemplates(yamlConfig);
72
75
  if (flags.json) {
73
76
  this.log(JSON.stringify({
74
77
  deployed: response.data.deployed,
@@ -86,6 +89,7 @@ export default class Deploy extends BaseCommand {
86
89
  collectDomainInputs: (yaml, skip) => this.collectDomainSetupInputs(yaml, skip),
87
90
  error: (msg) => this.error(msg),
88
91
  exit: (code) => this.exit(code),
92
+ fetchTemplate: (emailId) => this.getTemplateFromServer(emailId),
89
93
  get: (path, params) => this.apiClient.get(path, params),
90
94
  getBillingCap: async () => {
91
95
  const s = await this.fetchBillingStatus();
@@ -97,6 +101,7 @@ export default class Deploy extends BaseCommand {
97
101
  registerDomainAndSave: (yaml, inputs, json) => this.registerDomain(yaml, inputs, json),
98
102
  showDnsRecords: (records, url, json) => this.logDnsRecords(records, url, json),
99
103
  spinner: (text, json, work) => this.withApiSpinner({ json, text }, work),
104
+ syncTemplates: (yaml) => this.syncTemplatesToServer(yaml),
100
105
  syncYaml: () => this.syncYamlToServer(),
101
106
  };
102
107
  }
@@ -1,6 +1,6 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { BaseCommand } from '../../lib/base-command.js';
3
- import { getTemplateFilename, loadTemplate } from '../../lib/yaml-config.js';
3
+ import { getTemplateFilename, loadTemplate, } from '../../lib/yaml-config.js';
4
4
  import { handleMissingTemplates } from '../../lib/templates/missing-templates.js';
5
5
  import { askChangeDescription, runEditStep, } from '../../lib/commands/edit/flow.js';
6
6
  export default class Edit extends BaseCommand {
@@ -63,6 +63,7 @@ export default class Edit extends BaseCommand {
63
63
  post: (path, body) => this.apiClient.post(path, body),
64
64
  runCommand: (id, argv) => this.config.runCommand(id, argv),
65
65
  spinner: (text, json, work) => this.withApiSpinner({ json, text }, work),
66
+ syncTemplate: (emailId) => this.syncTemplateToServer(emailId),
66
67
  syncYaml: () => this.syncYamlToServer(),
67
68
  };
68
69
  }
@@ -70,10 +71,12 @@ export default class Edit extends BaseCommand {
70
71
  return {
71
72
  error: (msg) => this.error(msg),
72
73
  exit: (code) => this.exit(code),
74
+ fetchTemplate: (emailId) => this.getTemplateFromServer(emailId),
73
75
  log: (msg) => this.log(msg),
74
76
  onApiError: (r) => this.handleApiError(r),
75
77
  post: (path, body) => this.apiClient.post(path, body),
76
78
  spinner: (text, json, work) => this.withApiSpinner({ json, text }, work),
79
+ syncTemplates: (yaml) => this.syncTemplatesToServer(yaml),
77
80
  syncYaml: () => this.syncYamlToServer(),
78
81
  };
79
82
  }
@@ -20,6 +20,11 @@ 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
+ if (this.isBlankDirectory()) {
24
+ const restored = await this.promptInitServerRestore(baseFlags);
25
+ if (restored)
26
+ return;
27
+ }
23
28
  const existing = await loadYaml();
24
29
  if (!(await confirmOverwrite(ctx, baseFlags, existing)))
25
30
  return;
@@ -39,6 +44,7 @@ export default class Init extends BaseCommand {
39
44
  await applyMonthlyCap(ctx, yamlConfig);
40
45
  await saveAllTemplates(analysisPayload.recommendedEmails, generatedEmails);
41
46
  await ctx.syncYaml();
47
+ await this.syncTemplatesToServer(yamlConfig);
42
48
  logInitSuccess(ctx, {
43
49
  brand: analysisPayload.brand,
44
50
  emailConfigs,
@@ -27,9 +27,7 @@ export default class Login extends BaseCommand {
27
27
  if (!envKey) {
28
28
  const existing = await loadConfig();
29
29
  if (existing?.apiKey) {
30
- const existingClient = new ApiClient(existing.apiKey);
31
- const yamlRestored = await this.recoverYamlAfterLogin(existingClient);
32
- logAlreadyLoggedIn(ctx, existing, yamlRestored, { json: flags.json });
30
+ logAlreadyLoggedIn(ctx, existing, { json: flags.json });
33
31
  return;
34
32
  }
35
33
  }
@@ -42,8 +40,7 @@ export default class Login extends BaseCommand {
42
40
  }
43
41
  const { email, totalFreeRemaining, paidEmailsRemaining, plan } = response.data;
44
42
  await saveConfig({ apiKey: trimmedKey, email, totalFreeRemaining });
45
- const yamlRestored = await this.recoverYamlAfterLogin(client);
46
- logLoginSuccess(ctx, { email, paidEmailsRemaining, plan, totalFreeRemaining }, yamlRestored, { json: flags.json });
43
+ logLoginSuccess(ctx, { email, paidEmailsRemaining, plan, totalFreeRemaining }, { json: flags.json });
47
44
  }
48
45
  makeCtx() {
49
46
  return {
@@ -82,10 +82,12 @@ export default class Preview extends BaseCommand {
82
82
  return {
83
83
  error: (msg) => this.error(msg),
84
84
  exit: (code) => this.exit(code),
85
+ fetchTemplate: (emailId) => this.getTemplateFromServer(emailId),
85
86
  log: (msg) => this.log(msg),
86
87
  onApiError: (r) => this.handleApiError(r),
87
88
  post: (path, body) => this.apiClient.post(path, body),
88
89
  spinner: (text, json, work) => this.withApiSpinner({ json, text }, work),
90
+ syncTemplates: (yaml) => this.syncTemplatesToServer(yaml),
89
91
  syncYaml: () => this.syncYamlToServer(),
90
92
  };
91
93
  }
@@ -68,28 +68,53 @@ export declare abstract class BaseCommand extends Command {
68
68
  * settings and all email sequence definitions.
69
69
  */
70
70
  protected ensureYaml(): Promise<MailmodoYaml>;
71
+ protected isBlankDirectory(): boolean;
72
+ protected fetchYamlText(): Promise<null | string>;
73
+ private promptBlankDirRestore;
74
+ protected promptInitServerRestore(flags: {
75
+ json?: boolean;
76
+ yes?: boolean;
77
+ }): Promise<boolean>;
71
78
  private fetchAndWriteYaml;
72
79
  /**
73
80
  * Attempts to fetch mailmodo.yaml from the server and save it locally.
74
81
  * Returns null silently on any failure so callers can fall through to an error.
75
82
  */
76
83
  private restoreYamlFromServer;
77
- /**
78
- * If `mailmodo.yaml` is absent from the current directory, attempts to restore
79
- * it from the server using the given client. Returns `true` if the file was
80
- * successfully written, `false` otherwise (file already present, server 404,
81
- * or any network error). Silent — never throws.
82
- *
83
- * Used by `mailmodo login` right after the API key is validated so a returning
84
- * user automatically gets their config back without having to run `init` again.
85
- */
86
- protected recoverYamlAfterLogin(client: ApiClient): Promise<boolean>;
87
84
  /**
88
85
  * Uploads the current local mailmodo.yaml to the server as a backup.
89
86
  * Best-effort: silently ignores all errors so the originating command
90
87
  * always succeeds regardless of sync failures.
91
88
  */
92
89
  protected syncYamlToServer(): Promise<void>;
90
+ /**
91
+ * Bulk-uploads all template HTML files referenced in the YAML to the server
92
+ * as a backup. Best-effort: silently ignores all errors so the originating
93
+ * command always succeeds regardless of sync failures.
94
+ * Called after init, deploy, and AI regeneration.
95
+ */
96
+ protected syncTemplatesToServer(yaml: MailmodoYaml): Promise<void>;
97
+ /**
98
+ * Uploads a single template's HTML files to the server for incremental sync.
99
+ * Best-effort: silently ignores all errors. Called after the edit command
100
+ * applies changes to a specific template.
101
+ */
102
+ protected syncTemplateToServer(emailId: string): Promise<void>;
103
+ /**
104
+ * Fetches a single backed-up template from the server and writes it to the
105
+ * local mailmodo/ folder. Returns true if the template was successfully
106
+ * restored, false if it is not backed up or on any error.
107
+ * Used as the `ctx.fetchTemplate` bridge passed to handleMissingTemplates.
108
+ */
109
+ protected getTemplateFromServer(emailId: string): Promise<boolean>;
110
+ /**
111
+ * Silently restores any template files missing from disk by fetching them
112
+ * from the server. Uses the bulk endpoint when all templates are missing
113
+ * (single round-trip), or individual per-ID requests when only a subset is
114
+ * missing to avoid overwriting files that are already present. Best-effort —
115
+ * never throws.
116
+ */
117
+ private fetchMissingTemplates;
93
118
  /**
94
119
  * Handles a failed API response by mapping HTTP status codes to
95
120
  * user-friendly error messages and exiting the process.
@@ -1,14 +1,16 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readFile, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { input } from '@inquirer/prompts';
4
+ import { input, select } from '@inquirer/prompts';
5
5
  import { Command, Flags } from '@oclif/core';
6
6
  import chalk from 'chalk';
7
7
  import ora from 'ora';
8
8
  import { ApiClient } from './api-client.js';
9
9
  import { loadConfig } from './config.js';
10
- import { API_ENDPOINTS, IS_DEV_MODE, YAML_FILE } from './constants.js';
11
- import { ERRORS, INFO, PROMPTS, quotaExhaustedMessage, recordLabel, VALIDATION, } from './messages.js';
10
+ import { API_ENDPOINTS, IS_DEV_MODE, TEMPLATES_DIR, YAML_FILE, } from './constants.js';
11
+ import { syncTemplatesToServer, syncTemplateToServer, fetchTemplatesFromServer, fetchTemplateFromServer, } from './templates/sync.js';
12
+ import { getMissingTemplateIds } from './templates/missing-templates.js';
13
+ import { BLANK_DIR, ERRORS, INFO, PROMPTS, quotaExhaustedMessage, recordLabel, VALIDATION, } from './messages.js';
12
14
  import { loadYaml, saveYaml } from './yaml-config.js';
13
15
  export const FREE_TIER = 'free';
14
16
  /**
@@ -90,11 +92,89 @@ export class BaseCommand extends Command {
90
92
  const config = await loadYaml();
91
93
  if (config)
92
94
  return config;
95
+ if (this.isBlankDirectory())
96
+ return this.promptBlankDirRestore();
93
97
  const restored = await this.restoreYamlFromServer();
94
98
  if (restored)
95
99
  return restored;
96
100
  this.error(ERRORS.NO_YAML);
97
101
  }
102
+ isBlankDirectory() {
103
+ return (!existsSync(join(process.cwd(), YAML_FILE)) &&
104
+ !existsSync(join(process.cwd(), TEMPLATES_DIR)));
105
+ }
106
+ async fetchYamlText() {
107
+ try {
108
+ let client = this.apiClient;
109
+ if (!client) {
110
+ const apiKey = process.env.MAILMODO_API_KEY ?? (await loadConfig())?.apiKey;
111
+ if (!apiKey)
112
+ return null;
113
+ client = new ApiClient(apiKey);
114
+ }
115
+ const response = await client.getRawText(API_ENDPOINTS.ASSETS_YAML);
116
+ return response.ok && response.data ? response.data : null;
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
122
+ async promptBlankDirRestore() {
123
+ const yamlText = await this.fetchYamlText();
124
+ if (!yamlText)
125
+ this.error(ERRORS.NO_YAML);
126
+ const autoRestore = this.argv.includes('--yes') || this.argv.includes('--json');
127
+ if (!autoRestore) {
128
+ this.log(`\n ${BLANK_DIR.PROMPT}\n`);
129
+ const choice = await select({
130
+ choices: [
131
+ { name: BLANK_DIR.CHOICE_RESTORE, value: 'restore' },
132
+ { name: BLANK_DIR.CHOICE_SKIP, value: 'skip' },
133
+ ],
134
+ message: 'How would you like to proceed?',
135
+ });
136
+ if (choice === 'skip') {
137
+ this.log(`\n ${BLANK_DIR.SKIP_HINT}\n`);
138
+ this.exit(0);
139
+ }
140
+ }
141
+ await writeFile(join(process.cwd(), YAML_FILE), yamlText);
142
+ this.logToStderr(INFO.YAML_RESTORED_FROM_SERVER);
143
+ const yaml = await loadYaml();
144
+ if (!yaml)
145
+ this.error(ERRORS.NO_YAML);
146
+ const client = this.apiClient;
147
+ if (client)
148
+ await this.fetchMissingTemplates(client, yaml);
149
+ return yaml;
150
+ }
151
+ async promptInitServerRestore(flags) {
152
+ const yamlText = await this.fetchYamlText();
153
+ if (!yamlText)
154
+ return false;
155
+ let shouldRestore = Boolean(flags.yes || flags.json);
156
+ if (!shouldRestore) {
157
+ const choice = await select({
158
+ choices: [
159
+ { name: BLANK_DIR.CHOICE_RESTORE_INIT, value: 'restore' },
160
+ { name: BLANK_DIR.CHOICE_FRESH, value: 'fresh' },
161
+ ],
162
+ message: BLANK_DIR.PROMPT_INIT,
163
+ });
164
+ shouldRestore = choice === 'restore';
165
+ }
166
+ if (!shouldRestore)
167
+ return false;
168
+ await writeFile(join(process.cwd(), YAML_FILE), yamlText);
169
+ const yaml = await loadYaml();
170
+ if (!yaml)
171
+ return false;
172
+ const client = this.apiClient;
173
+ if (client)
174
+ await this.fetchMissingTemplates(client, yaml);
175
+ this.log(`\n ${BLANK_DIR.RESTORED_INIT}\n`);
176
+ return true;
177
+ }
98
178
  async fetchAndWriteYaml(client) {
99
179
  try {
100
180
  const response = await client.getRawText(API_ENDPOINTS.ASSETS_YAML);
@@ -131,20 +211,6 @@ export class BaseCommand extends Command {
131
211
  return null;
132
212
  }
133
213
  }
134
- /**
135
- * If `mailmodo.yaml` is absent from the current directory, attempts to restore
136
- * it from the server using the given client. Returns `true` if the file was
137
- * successfully written, `false` otherwise (file already present, server 404,
138
- * or any network error). Silent — never throws.
139
- *
140
- * Used by `mailmodo login` right after the API key is validated so a returning
141
- * user automatically gets their config back without having to run `init` again.
142
- */
143
- async recoverYamlAfterLogin(client) {
144
- if (existsSync(join(process.cwd(), YAML_FILE)))
145
- return false;
146
- return this.fetchAndWriteYaml(client);
147
- }
148
214
  /**
149
215
  * Uploads the current local mailmodo.yaml to the server as a backup.
150
216
  * Best-effort: silently ignores all errors so the originating command
@@ -173,6 +239,92 @@ export class BaseCommand extends Command {
173
239
  // Silently ignore — local file remains authoritative
174
240
  }
175
241
  }
242
+ /**
243
+ * Bulk-uploads all template HTML files referenced in the YAML to the server
244
+ * as a backup. Best-effort: silently ignores all errors so the originating
245
+ * command always succeeds regardless of sync failures.
246
+ * Called after init, deploy, and AI regeneration.
247
+ */
248
+ async syncTemplatesToServer(yaml) {
249
+ try {
250
+ let client = this.apiClient;
251
+ if (!client) {
252
+ const envKey = process.env.MAILMODO_API_KEY;
253
+ const apiKey = envKey ?? (await loadConfig())?.apiKey;
254
+ if (!apiKey)
255
+ return;
256
+ client = new ApiClient(apiKey);
257
+ }
258
+ await syncTemplatesToServer(client, yaml);
259
+ }
260
+ catch {
261
+ // Silently ignore — local files remain authoritative
262
+ }
263
+ }
264
+ /**
265
+ * Uploads a single template's HTML files to the server for incremental sync.
266
+ * Best-effort: silently ignores all errors. Called after the edit command
267
+ * applies changes to a specific template.
268
+ */
269
+ async syncTemplateToServer(emailId) {
270
+ try {
271
+ let client = this.apiClient;
272
+ if (!client) {
273
+ const envKey = process.env.MAILMODO_API_KEY;
274
+ const apiKey = envKey ?? (await loadConfig())?.apiKey;
275
+ if (!apiKey)
276
+ return;
277
+ client = new ApiClient(apiKey);
278
+ }
279
+ await syncTemplateToServer(client, emailId);
280
+ }
281
+ catch {
282
+ // Silently ignore — local files remain authoritative
283
+ }
284
+ }
285
+ /**
286
+ * Fetches a single backed-up template from the server and writes it to the
287
+ * local mailmodo/ folder. Returns true if the template was successfully
288
+ * restored, false if it is not backed up or on any error.
289
+ * Used as the `ctx.fetchTemplate` bridge passed to handleMissingTemplates.
290
+ */
291
+ async getTemplateFromServer(emailId) {
292
+ try {
293
+ let client = this.apiClient;
294
+ if (!client) {
295
+ const envKey = process.env.MAILMODO_API_KEY;
296
+ const apiKey = envKey ?? (await loadConfig())?.apiKey;
297
+ if (!apiKey)
298
+ return false;
299
+ client = new ApiClient(apiKey);
300
+ }
301
+ return fetchTemplateFromServer(client, emailId);
302
+ }
303
+ catch {
304
+ return false;
305
+ }
306
+ }
307
+ /**
308
+ * Silently restores any template files missing from disk by fetching them
309
+ * from the server. Uses the bulk endpoint when all templates are missing
310
+ * (single round-trip), or individual per-ID requests when only a subset is
311
+ * missing to avoid overwriting files that are already present. Best-effort —
312
+ * never throws.
313
+ */
314
+ async fetchMissingTemplates(client, yaml) {
315
+ try {
316
+ const missingIds = getMissingTemplateIds(yaml);
317
+ if (missingIds.length === 0)
318
+ return;
319
+ // Use bulk endpoint when every template is missing; per-ID otherwise
320
+ await (missingIds.length === yaml.emails.length
321
+ ? fetchTemplatesFromServer(client, yaml)
322
+ : Promise.all(missingIds.map((id) => fetchTemplateFromServer(client, id))));
323
+ }
324
+ catch {
325
+ // best-effort, silently ignore
326
+ }
327
+ }
176
328
  /**
177
329
  * Handles a failed API response by mapping HTTP status codes to
178
330
  * user-friendly error messages and exiting the process.
@@ -42,12 +42,12 @@ export function buildDiffPreview(email, updated, templateHtml) {
42
42
  diff.subject = subjectChanged
43
43
  ? { new: updated.subject, old: email.subject }
44
44
  : { unchanged: true, value: email.subject };
45
- if (email.previewText ?? updated.previewText) {
45
+ if (email.previewText || updated.previewText) {
46
46
  diff.previewText = previewChanged
47
47
  ? { new: updated.previewText, old: email.previewText }
48
48
  : { unchanged: true, value: email.previewText };
49
49
  }
50
- if (templateHtml ?? updated.html) {
50
+ if (templateHtml || updated.html) {
51
51
  const oldText = templateHtml
52
52
  ? truncate(stripHtml(templateHtml), 500)
53
53
  : null;
@@ -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 = {
@@ -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";
@@ -30,7 +31,7 @@ export declare const API_ENDPOINTS: Readonly<{
30
31
  SEQUENCES_SDK: "/sequences/sdk";
31
32
  SEQUENCES_VALIDATE: "/sequences/validate";
32
33
  }>;
33
- export declare const LOGIN_URL = "https://app-vertex-debug.azurewebsites.net/signup.html";
34
+ export declare const LOGIN_URL: string;
34
35
  export declare const PREVIEW_PORT = 3421;
35
36
  export declare const DEFAULT_BRAND_COLOR = "#1A56DB";
36
37
  export declare const TEMPLATES_DIR = "mailmodo";
@@ -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',
@@ -36,9 +36,8 @@ export const API_ENDPOINTS = Object.freeze({
36
36
  SEQUENCES_SDK: '/sequences/sdk',
37
37
  SEQUENCES_VALIDATE: '/sequences/validate',
38
38
  });
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';
39
+ const DEV_LOGIN_URL = 'https://app-vertex-debug.azurewebsites.net/signup';
40
+ const PRODUCTION_LOGIN_URL = 'https://app.mailmodo.dev/signup';
42
41
  export const LOGIN_URL = process.env.MAILMODO_DEV_TSX
43
42
  ? DEV_LOGIN_URL
44
43
  : 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 — create a new project here";
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)";
@@ -63,6 +73,7 @@ export declare const DEPLOY: {
63
73
  readonly SDK_ONBOARDING_HEADER: string;
64
74
  readonly SUCCESS: `${string} Emails are live.`;
65
75
  };
76
+ export declare function restoredFromServerHint(ids: string[]): string;
66
77
  export declare function pauseSuccess(sequenceId: string): string;
67
78
  export declare function pauseAlready(sequenceId: string): string;
68
79
  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 — create a new project here',
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)',
@@ -63,6 +73,27 @@ export const DEPLOY = {
63
73
  SDK_ONBOARDING_HEADER: chalk.bold('ADD THIS TO YOUR APP (one-time only):'),
64
74
  SUCCESS: `${chalk.green('Deployed.')} Emails are live.`,
65
75
  };
76
+ export function restoredFromServerHint(ids) {
77
+ if (ids.length === 1) {
78
+ const [id] = ids;
79
+ return (`Template ${chalk.cyan(id)} was not found locally and has been refreshed from the server.\n` +
80
+ ` Review it with ${chalk.cyan(`mailmodo preview ${id}`)}, then run ${chalk.cyan('mailmodo deploy')} again.`);
81
+ }
82
+ const header = 'Template ID';
83
+ const colWidth = Math.max(header.length, ...ids.map((id) => id.length));
84
+ const pad = (s) => s.padEnd(colWidth);
85
+ const hr = '─'.repeat(colWidth + 2);
86
+ const table = [
87
+ ` ┌${hr}┐`,
88
+ ` │ ${chalk.bold(pad(header))} │`,
89
+ ` ├${hr}┤`,
90
+ ...ids.map((id) => ` │ ${chalk.cyan(pad(id))} │`),
91
+ ` └${hr}┘`,
92
+ ].join('\n');
93
+ return (`${ids.length} templates were not found locally and have been refreshed from the server:\n\n` +
94
+ `${table}\n\n` +
95
+ ` Review each with ${chalk.cyan('mailmodo preview <id>')}, then run ${chalk.cyan('mailmodo deploy')} again.`);
96
+ }
66
97
  export function pauseSuccess(sequenceId) {
67
98
  return `Sequence ${chalk.cyan(sequenceId)} paused. Run ${chalk.cyan(`mailmodo deploy --resume ${sequenceId}`)} to resume.`;
68
99
  }
@@ -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
  };
@@ -765,5 +765,5 @@
765
765
  ]
766
766
  }
767
767
  },
768
- "version": "0.0.55-beta.pr57.93"
768
+ "version": "0.0.56-beta.pr58.100"
769
769
  }
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.55-beta.pr57.93",
4
+ "version": "0.0.56-beta.pr58.100",
5
5
  "author": "provishalk",
6
6
  "bin": {
7
7
  "mailmodo": "bin/run.js"