@mailmodo/cli 0.0.19 → 0.0.20-beta.pr22.32

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.
@@ -10,9 +10,18 @@ export default class Deploy extends BaseCommand {
10
10
  * Fetches current DNS verification status for the deploy flow.
11
11
  *
12
12
  * @param jsonOutput - When true, spinner uses stderr for stdout-safe JSON runs.
13
+ * @param domain - Sending domain to check. If empty, request will fail and trigger setup flow.
13
14
  */
14
15
  private fetchDomainVerifyForDeploy;
15
16
  run(): Promise<void>;
17
+ private buildDeployPayload;
18
+ private mapEmailToPayload;
19
+ private buildBrandSection;
20
+ private buildProductSection;
21
+ private buildSenderSection;
22
+ private buildProjectPayload;
23
+ private confirmDeploy;
24
+ private ensureDomainReady;
16
25
  /**
17
26
  * Lists emails about to be deployed (skipped when `--json` is set).
18
27
  *
@@ -32,9 +41,11 @@ export default class Deploy extends BaseCommand {
32
41
  * @returns {Promise<boolean>} true if domain was verified, false if skipped.
33
42
  */
34
43
  private runDomainSetup;
44
+ private collectDomainInputs;
45
+ private showDnsRecords;
35
46
  /**
36
47
  * Calls the domain verification API endpoint and reports pass/fail
37
- * status for each DNS record (SPF, DKIM, DMARC).
48
+ * status for each DNS record (DKIM, DMARC, Return-Path).
38
49
  *
39
50
  * @returns {Promise<boolean>} true if all records pass.
40
51
  */
@@ -1,8 +1,8 @@
1
1
  import { confirm, input } from '@inquirer/prompts';
2
2
  import chalk from 'chalk';
3
3
  import { BaseCommand } from '../../lib/base-command.js';
4
- import { API_ENDPOINTS, DNS_GUIDE_URL } from '../../lib/constants.js';
5
- import { saveYaml } from '../../lib/yaml-config.js';
4
+ import { API_ENDPOINTS, DEFAULT_BRAND_COLOR, DEFAULT_MONTHLY_CAP, DNS_GUIDE_URL, } from '../../lib/constants.js';
5
+ import { loadTemplate, saveYaml, } from '../../lib/yaml-config.js';
6
6
  export default class Deploy extends BaseCommand {
7
7
  static description = 'Deploy email sequences and verify sending domain';
8
8
  static examples = [
@@ -16,69 +16,139 @@ export default class Deploy extends BaseCommand {
16
16
  * Fetches current DNS verification status for the deploy flow.
17
17
  *
18
18
  * @param jsonOutput - When true, spinner uses stderr for stdout-safe JSON runs.
19
+ * @param domain - Sending domain to check. If empty, request will fail and trigger setup flow.
19
20
  */
20
- fetchDomainVerifyForDeploy(jsonOutput) {
21
- return this.withApiSpinner({ json: jsonOutput, text: ' Checking domain verification...' }, () => this.apiClient.get(API_ENDPOINTS.DOMAIN_VERIFY));
21
+ fetchDomainVerifyForDeploy(jsonOutput, domain) {
22
+ return this.withApiSpinner({ json: jsonOutput, text: ' Checking domain verification...' }, () => this.apiClient.get(API_ENDPOINTS.DOMAIN_VERIFY, {
23
+ domain: domain || '',
24
+ }));
22
25
  }
23
26
  async run() {
24
27
  const { flags } = await this.parse(Deploy);
25
28
  await this.ensureAuth();
26
29
  const yamlConfig = await this.ensureYaml();
27
- const domainVerify = await this.fetchDomainVerifyForDeploy(flags.json);
28
- const domainVerified = domainVerify.ok &&
29
- domainVerify.data?.spf === 'pass' &&
30
- domainVerify.data?.dkim === 'pass' &&
31
- domainVerify.data?.dmarc === 'pass';
32
- if (!domainVerified) {
33
- if (!flags.json) {
34
- this.log(`\n No sending domain verified yet.`);
35
- this.log(` You need to verify a domain before sending emails.`);
36
- this.log(` This is a one-time setup. Takes about 5 minutes.\n`);
37
- }
38
- if (!flags.yes) {
39
- const setupNow = await confirm({
40
- default: true,
41
- message: 'Set up your sending domain now?',
42
- });
43
- if (!setupNow) {
44
- this.log(`\n Sequences saved but ${chalk.yellow('NOT deployed')}.`);
45
- this.log(` Emails will not send until your domain is verified.`);
46
- this.log(` When ready, run: ${chalk.cyan('mailmodo domain')}`);
47
- this.log(` Then: ${chalk.cyan('mailmodo deploy')}\n`);
48
- return;
49
- }
50
- }
51
- const completed = await this.runDomainSetup(yamlConfig, flags);
52
- if (!completed)
53
- return;
54
- }
30
+ const domainReady = await this.ensureDomainReady(yamlConfig, flags);
31
+ if (!domainReady)
32
+ return;
55
33
  this.logPreDeploySummary(yamlConfig, flags.json);
56
- if (!flags.yes) {
57
- const proceed = await confirm({
58
- default: true,
59
- message: `Deploy ${yamlConfig.emails.length} emails?`,
60
- });
61
- if (!proceed) {
62
- this.log('\n Deploy cancelled.\n');
63
- return;
64
- }
65
- }
66
- const response = await this.withApiSpinner({ json: flags.json, text: ' Deploying email sequences...' }, () => this.apiClient.post(API_ENDPOINTS.SEQUENCES, {
67
- emails: yamlConfig.emails,
68
- project: yamlConfig.project,
69
- }));
34
+ const confirmed = await this.confirmDeploy(yamlConfig, flags);
35
+ if (!confirmed)
36
+ return;
37
+ const payload = await this.buildDeployPayload(yamlConfig);
38
+ const response = await this.withApiSpinner({ json: flags.json, text: ' Deploying email sequences...' }, () => this.apiClient.post(API_ENDPOINTS.SEQUENCES_DEPLOY, payload));
70
39
  if (!response.ok) {
71
40
  this.handleApiError(response);
72
41
  }
73
42
  if (flags.json) {
74
43
  this.log(JSON.stringify({
75
- deployed: true,
76
- emailsLive: yamlConfig.emails.length,
77
- sdkSnippet: response.data?.sdkSnippet,
44
+ deployed: response.data.deployed,
45
+ diff: response.data.diff,
46
+ emailsLive: response.data.emailsLive,
47
+ sdkSnippet: response.data.sdkSnippet,
48
+ sequenceId: response.data.sequenceId,
78
49
  }, null, 2));
79
50
  return;
80
51
  }
81
- this.logDeploySuccessInstructions();
52
+ this.logDeploySuccessInstructions(response.data.sdkSnippet);
53
+ }
54
+ async buildDeployPayload(yamlConfig) {
55
+ const emailsWithHtml = await Promise.all(yamlConfig.emails.map(async (email) => {
56
+ const html = (await loadTemplate(`${email.id}.html`)) || '';
57
+ return { ...this.mapEmailToPayload(email), html, plainHtml: html };
58
+ }));
59
+ return {
60
+ ...this.buildProjectPayload(yamlConfig.project),
61
+ emails: emailsWithHtml,
62
+ };
63
+ }
64
+ mapEmailToPayload(email) {
65
+ return {
66
+ condition: email.condition || null,
67
+ ctaText: '',
68
+ delay: typeof email.delay === 'string'
69
+ ? Number.parseInt(email.delay, 10) || 0
70
+ : email.delay,
71
+ goal: email.goal || '',
72
+ id: email.id,
73
+ isReminder: false,
74
+ previewText: email.previewText || '',
75
+ priority: 'medium',
76
+ subject: email.subject,
77
+ trigger: email.trigger,
78
+ };
79
+ }
80
+ buildBrandSection(project) {
81
+ return {
82
+ colors: [project?.brandColor || DEFAULT_BRAND_COLOR],
83
+ logoUrl: project?.logoUrl || '',
84
+ };
85
+ }
86
+ buildProductSection(project) {
87
+ return {
88
+ businessType: project?.type || '',
89
+ description: '',
90
+ pricingModel: '',
91
+ productName: project?.name || '',
92
+ saasModel: '',
93
+ targetUser: '',
94
+ url: project?.url || '',
95
+ };
96
+ }
97
+ buildSenderSection(project) {
98
+ return {
99
+ address: project?.address || '',
100
+ domain: project?.domain || '',
101
+ fromEmail: project?.fromEmail || '',
102
+ fromName: project?.fromName || '',
103
+ replyTo: project?.replyTo || project?.fromEmail || '',
104
+ };
105
+ }
106
+ buildProjectPayload(project) {
107
+ return {
108
+ brand: this.buildBrandSection(project),
109
+ emailStyle: project?.emailStyle || 'branded',
110
+ monthlyCap: project?.monthlyCap ?? DEFAULT_MONTHLY_CAP,
111
+ product: this.buildProductSection(project),
112
+ senderDetails: this.buildSenderSection(project),
113
+ webhookUrl: project?.webhookUrl || '',
114
+ };
115
+ }
116
+ async confirmDeploy(yamlConfig, flags) {
117
+ if (flags.yes)
118
+ return true;
119
+ const proceed = await confirm({
120
+ default: true,
121
+ message: `Deploy ${yamlConfig.emails.length} emails?`,
122
+ });
123
+ if (!proceed) {
124
+ this.log('\n Deploy cancelled.\n');
125
+ }
126
+ return proceed;
127
+ }
128
+ async ensureDomainReady(yamlConfig, flags) {
129
+ const domainVerify = await this.fetchDomainVerifyForDeploy(flags.json, yamlConfig.project?.domain);
130
+ if (domainVerify.ok && domainVerify.data?.domainStatus === 'VERIFIED') {
131
+ return true;
132
+ }
133
+ if (!flags.json) {
134
+ this.log(`\n No sending domain verified yet.`);
135
+ this.log(` You need to verify a domain before sending emails.`);
136
+ this.log(` This is a one-time setup. Takes about 5 minutes.\n`);
137
+ }
138
+ if (!flags.yes) {
139
+ const setupNow = await confirm({
140
+ default: true,
141
+ message: 'Set up your sending domain now?',
142
+ });
143
+ if (!setupNow) {
144
+ this.log(`\n Sequences saved but ${chalk.yellow('NOT deployed')}.`);
145
+ this.log(` Emails will not send until your domain is verified.`);
146
+ this.log(` When ready, run: ${chalk.cyan('mailmodo domain')}`);
147
+ this.log(` Then: ${chalk.cyan('mailmodo deploy')}\n`);
148
+ return false;
149
+ }
150
+ }
151
+ return this.runDomainSetup(yamlConfig, flags);
82
152
  }
83
153
  /**
84
154
  * Lists emails about to be deployed (skipped when `--json` is set).
@@ -99,19 +169,19 @@ export default class Deploy extends BaseCommand {
99
169
  /**
100
170
  * Prints the post-deploy success message and SDK install snippet for interactive runs.
101
171
  */
102
- logDeploySuccessInstructions() {
172
+ logDeploySuccessInstructions(sdkSnippet) {
103
173
  this.log(` ${chalk.green('Deployed.')} Emails are live.\n`);
104
174
  this.log(` ${'─'.repeat(53)}`);
105
175
  this.log(` ${chalk.bold('ADD THIS TO YOUR APP (one-time only):')}`);
106
176
  this.log(` ${'─'.repeat(53)}\n`);
107
- this.log(` ${chalk.cyan('npm install @mailmodo/sdk')}\n`);
177
+ this.log(` ${chalk.cyan(sdkSnippet.install ?? 'npm install @mailmodo/sdk')}\n`);
108
178
  this.log(` ${chalk.dim("import { track, identify } from '@mailmodo/sdk'")}\n`);
109
- this.log(` ${chalk.dim('// On user signup:')}`);
110
- this.log(` ${chalk.dim("track('user.signup', { email, first_name, app_url })")}\n`);
111
- this.log(` ${chalk.dim('// When user creates a project:')}`);
112
- this.log(` ${chalk.dim('identify(email, { has_created_project: true })')}\n`);
113
- this.log(` ${chalk.dim('// On trial expiry:')}`);
114
- this.log(` ${chalk.dim("track('user.trial_expiry', { email, first_name })")}\n`);
179
+ for (const [key, snippet] of Object.entries(sdkSnippet)) {
180
+ if (key === 'install')
181
+ continue;
182
+ this.log(` ${chalk.dim(snippet)}`);
183
+ }
184
+ this.log('');
115
185
  this.log(` Full SDK docs: ${chalk.cyan('mailmodo.com/docs/sdk')}\n`);
116
186
  this.log(` ${'─'.repeat(53)}\n`);
117
187
  }
@@ -123,31 +193,7 @@ export default class Deploy extends BaseCommand {
123
193
  * @returns {Promise<boolean>} true if domain was verified, false if skipped.
124
194
  */
125
195
  async runDomainSetup(yamlConfig, flags) {
126
- let domain;
127
- let senderEmail;
128
- let address;
129
- if (flags.yes) {
130
- domain = yamlConfig.project?.domain || '';
131
- senderEmail = yamlConfig.project?.fromEmail || '';
132
- address = yamlConfig.project?.address || '';
133
- }
134
- else {
135
- this.log(`\n ${'─'.repeat(53)}`);
136
- this.log(` ${chalk.bold('DOMAIN SETUP')}`);
137
- this.log(` ${'─'.repeat(53)}\n`);
138
- domain = await input({
139
- message: 'What domain will you send from?',
140
- validate: (v) => (v?.trim() ? true : 'Domain is required'),
141
- });
142
- senderEmail = await input({
143
- message: 'Sender email address:',
144
- validate: (v) => v?.includes('@') ? true : 'Please enter a valid email',
145
- });
146
- address = await input({
147
- message: 'Business address (required by law for email footers):',
148
- validate: (v) => (v?.trim() ? true : 'Address is required'),
149
- });
150
- }
196
+ const { address, domain, senderEmail } = await this.collectDomainInputs(yamlConfig, flags);
151
197
  const domainResponse = await this.withApiSpinner({ json: flags.json, text: ' Configuring domain...' }, () => this.apiClient.post(API_ENDPOINTS.DOMAIN, {
152
198
  address,
153
199
  domain,
@@ -160,20 +206,9 @@ export default class Deploy extends BaseCommand {
160
206
  yamlConfig.project.fromEmail = senderEmail;
161
207
  yamlConfig.project.address = address;
162
208
  await saveYaml(yamlConfig);
163
- const dnsRecords = domainResponse.data?.dnsRecords || [];
164
- if (!flags.json) {
165
- this.log(`\n Add these ${dnsRecords.length} DNS records to your domain provider:\n`);
166
- for (const [i, record] of dnsRecords.entries()) {
167
- this.log(` ${chalk.bold(`RECORD ${i + 1}`)}`);
168
- this.log(` Type: ${record.type}`);
169
- this.log(` Host: ${record.host}`);
170
- this.log(` Value: ${record.value}\n`);
171
- }
172
- this.log(` DNS changes take 5–30 minutes to propagate.`);
173
- this.log(` Full guide: ${chalk.cyan(DNS_GUIDE_URL)}\n`);
174
- }
209
+ this.showDnsRecords(domainResponse.data?.dnsRecords || [], flags.json);
175
210
  if (flags.yes) {
176
- return this.verifyDomain(flags.json);
211
+ return this.verifyDomain(flags.json, domain);
177
212
  }
178
213
  const action = await input({
179
214
  default: '',
@@ -185,25 +220,65 @@ export default class Deploy extends BaseCommand {
185
220
  this.log(` Then: ${chalk.cyan('mailmodo deploy')}\n`);
186
221
  return false;
187
222
  }
188
- return this.verifyDomain(flags.json);
223
+ return this.verifyDomain(flags.json, domain);
224
+ }
225
+ async collectDomainInputs(yamlConfig, flags) {
226
+ if (flags.yes) {
227
+ return {
228
+ address: yamlConfig.project?.address || '',
229
+ domain: yamlConfig.project?.domain || '',
230
+ senderEmail: yamlConfig.project?.fromEmail || '',
231
+ };
232
+ }
233
+ this.log(`\n ${'─'.repeat(53)}`);
234
+ this.log(` ${chalk.bold('DOMAIN SETUP')}`);
235
+ this.log(` ${'─'.repeat(53)}\n`);
236
+ const domain = await input({
237
+ message: 'What domain will you send from?',
238
+ validate: (v) => (v?.trim() ? true : 'Domain is required'),
239
+ });
240
+ const senderEmail = await input({
241
+ message: 'Sender email address:',
242
+ validate: (v) => (v?.includes('@') ? true : 'Please enter a valid email'),
243
+ });
244
+ const address = await input({
245
+ message: 'Business address (required by law for email footers):',
246
+ validate: (v) => (v?.trim() ? true : 'Address is required'),
247
+ });
248
+ return { address, domain, senderEmail };
249
+ }
250
+ showDnsRecords(dnsRecords, jsonOutput) {
251
+ if (jsonOutput)
252
+ return;
253
+ this.log(`\n Add these ${dnsRecords.length} DNS records to your domain provider:\n`);
254
+ for (const [i, record] of dnsRecords.entries()) {
255
+ this.log(` ${chalk.bold(`RECORD ${i + 1}`)}`);
256
+ this.log(` Type: ${record.type}`);
257
+ this.log(` Host: ${record.host}`);
258
+ this.log(` Value: ${record.value}\n`);
259
+ }
260
+ this.log(` DNS changes take 5–30 minutes to propagate.`);
261
+ this.log(` Full guide: ${chalk.cyan(DNS_GUIDE_URL)}\n`);
189
262
  }
190
263
  /**
191
264
  * Calls the domain verification API endpoint and reports pass/fail
192
- * status for each DNS record (SPF, DKIM, DMARC).
265
+ * status for each DNS record (DKIM, DMARC, Return-Path).
193
266
  *
194
267
  * @returns {Promise<boolean>} true if all records pass.
195
268
  */
196
- async verifyDomain(jsonOutput) {
197
- const verify = await this.withApiSpinner({ json: jsonOutput, text: ' Checking DNS...' }, () => this.apiClient.get(API_ENDPOINTS.DOMAIN_VERIFY));
269
+ async verifyDomain(jsonOutput, domain) {
270
+ const verify = await this.withApiSpinner({ json: jsonOutput, text: ' Checking DNS...' }, () => this.apiClient.get(API_ENDPOINTS.DOMAIN_VERIFY, {
271
+ domain,
272
+ }));
198
273
  if (!verify.ok) {
199
274
  this.handleApiError(verify);
200
275
  }
201
- const { dkim, dmarc, spf } = verify.data;
202
- const allPassed = spf === 'pass' && dkim === 'pass' && dmarc === 'pass';
276
+ const { dkim, dmarc, domainStatus, returnPath } = verify.data;
277
+ const allPassed = domainStatus === 'VERIFIED';
203
278
  if (!jsonOutput) {
204
- this.log(` SPF ${spf === 'pass' ? chalk.green('✓') : chalk.red('✗')}`);
205
- this.log(` DKIM ${dkim === 'pass' ? chalk.green('✓') : chalk.red('✗')}`);
206
- this.log(` DMARC ${dmarc === 'pass' ? chalk.green('✓') : chalk.red('✗')}`);
279
+ this.log(` DKIM ${dkim ? chalk.green('✓') : chalk.red('✗')}`);
280
+ this.log(` DMARC ${dmarc ? chalk.green('✓') : chalk.red('✗')}`);
281
+ this.log(` Return-Path ${returnPath ? chalk.green('✓') : chalk.red('✗')}`);
207
282
  if (allPassed) {
208
283
  this.log(`\n ${chalk.green('Domain verified.')} Continuing deploy...\n`);
209
284
  }
@@ -10,5 +10,15 @@ export default class Edit extends BaseCommand {
10
10
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
11
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
12
  };
13
+ private showFieldDiff;
14
+ private stripHtml;
15
+ private truncate;
16
+ private showHtmlChange;
17
+ private showUnchangedField;
18
+ private showUnchangedHtml;
19
+ private showSuggestedChanges;
20
+ private showUnchanged;
21
+ private buildDiffPreview;
22
+ private showChangeSummary;
13
23
  run(): Promise<void>;
14
24
  }
@@ -22,6 +22,123 @@ export default class Edit extends BaseCommand {
22
22
  description: 'Natural language description of the change',
23
23
  }),
24
24
  };
25
+ showFieldDiff(label, oldVal, newVal) {
26
+ if (!newVal || oldVal === newVal)
27
+ return false;
28
+ this.log(`\n ${label}:`);
29
+ if (oldVal)
30
+ this.log(` ${chalk.red(`- ${oldVal}`)}`);
31
+ this.log(` ${chalk.green(`+ ${newVal}`)}`);
32
+ return true;
33
+ }
34
+ stripHtml(html) {
35
+ return html
36
+ .replaceAll(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
37
+ .replaceAll(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
38
+ .replaceAll(/<[^>]+>/g, ' ')
39
+ .replaceAll('&nbsp;', ' ')
40
+ .replaceAll('&amp;', '&')
41
+ .replaceAll('&lt;', '<')
42
+ .replaceAll('&gt;', '>')
43
+ .replaceAll(/\s+/g, ' ')
44
+ .trim();
45
+ }
46
+ truncate(text, max) {
47
+ return text.length > max ? `${text.slice(0, max)}…` : text;
48
+ }
49
+ showHtmlChange(oldHtml, newHtml) {
50
+ if (!newHtml || oldHtml === newHtml)
51
+ return false;
52
+ this.log(`\n HTML Body:`);
53
+ const MAX = 500;
54
+ if (oldHtml) {
55
+ const oldText = this.truncate(this.stripHtml(oldHtml), MAX);
56
+ this.log(` ${chalk.red(`- ${oldText}`)}`);
57
+ }
58
+ const newText = this.truncate(this.stripHtml(newHtml), MAX);
59
+ this.log(` ${chalk.green(`+ ${newText}`)}`);
60
+ return true;
61
+ }
62
+ showUnchangedField(label, value) {
63
+ if (!value)
64
+ return;
65
+ this.log(`\n ${label}:`);
66
+ this.log(` ${chalk.dim(value)}`);
67
+ }
68
+ showUnchangedHtml(templateHtml) {
69
+ if (!templateHtml)
70
+ return;
71
+ this.log(`\n HTML Body:`);
72
+ this.log(` ${chalk.dim(this.truncate(this.stripHtml(templateHtml), 500))}`);
73
+ }
74
+ showSuggestedChanges(email, updated, templateHtml, changed) {
75
+ this.log('\n Suggested Changes:');
76
+ if (changed.subject)
77
+ this.showFieldDiff('Subject', email.subject, updated.subject);
78
+ if (changed.preview)
79
+ this.showFieldDiff('Preview Text', email.previewText, updated.previewText);
80
+ if (changed.html)
81
+ this.showHtmlChange(templateHtml, updated.html);
82
+ if (changed.cta)
83
+ this.showFieldDiff('CTA Text', undefined, updated.ctaText);
84
+ if (!changed.subject && !changed.preview && !changed.html && !changed.cta) {
85
+ this.log(`\n ${chalk.dim('No changes detected.')}`);
86
+ }
87
+ }
88
+ showUnchanged(email, templateHtml, changed) {
89
+ const hasContent = !changed.subject ||
90
+ (!changed.preview && Boolean(email.previewText)) ||
91
+ (!changed.html && Boolean(templateHtml));
92
+ if (!hasContent)
93
+ return;
94
+ this.log('\n Unchanged:');
95
+ if (!changed.subject)
96
+ this.showUnchangedField('Subject', email.subject);
97
+ if (!changed.preview)
98
+ this.showUnchangedField('Preview Text', email.previewText);
99
+ if (!changed.html)
100
+ this.showUnchangedHtml(templateHtml);
101
+ }
102
+ buildDiffPreview(email, updated, templateHtml) {
103
+ const subjectChanged = Boolean(updated.subject) && updated.subject !== email.subject;
104
+ const previewChanged = Boolean(updated.previewText) && updated.previewText !== email.previewText;
105
+ const htmlChanged = Boolean(updated.html) && updated.html !== templateHtml;
106
+ const diff = {};
107
+ diff.subject = subjectChanged
108
+ ? { new: updated.subject, old: email.subject }
109
+ : { unchanged: true, value: email.subject };
110
+ if (email.previewText ?? updated.previewText) {
111
+ diff.previewText = previewChanged
112
+ ? { new: updated.previewText, old: email.previewText }
113
+ : { unchanged: true, value: email.previewText };
114
+ }
115
+ if (templateHtml ?? updated.html) {
116
+ const oldText = templateHtml
117
+ ? this.truncate(this.stripHtml(templateHtml), 500)
118
+ : null;
119
+ const newText = updated.html
120
+ ? this.truncate(this.stripHtml(updated.html), 500)
121
+ : null;
122
+ diff.html = htmlChanged
123
+ ? { new: newText, old: oldText }
124
+ : { unchanged: true, value: oldText };
125
+ }
126
+ if (updated.ctaText) {
127
+ diff.ctaText = { new: updated.ctaText };
128
+ }
129
+ return { diff };
130
+ }
131
+ showChangeSummary(email, updated, templateHtml) {
132
+ const changed = {
133
+ cta: Boolean(updated.ctaText),
134
+ html: Boolean(updated.html) && updated.html !== templateHtml,
135
+ preview: Boolean(updated.previewText) &&
136
+ updated.previewText !== email.previewText,
137
+ subject: Boolean(updated.subject) && updated.subject !== email.subject,
138
+ };
139
+ this.showSuggestedChanges(email, updated, templateHtml, changed);
140
+ this.showUnchanged(email, templateHtml, changed);
141
+ }
25
142
  async run() {
26
143
  const { args, flags } = await this.parse(Edit);
27
144
  await this.ensureAuth();
@@ -32,9 +149,6 @@ export default class Edit extends BaseCommand {
32
149
  }
33
150
  const email = yamlConfig.emails[emailIndex];
34
151
  const templateHtml = await loadTemplate(`${email.id}.html`);
35
- if (!flags.json) {
36
- this.log(`\n Current subject: '${chalk.cyan(email.subject)}'`);
37
- }
38
152
  let changeDescription = flags.change;
39
153
  if (!changeDescription) {
40
154
  changeDescription = await input({
@@ -58,12 +172,11 @@ export default class Edit extends BaseCommand {
58
172
  this.handleApiError(response);
59
173
  }
60
174
  const updated = response.data;
61
- const oldSubject = email.subject;
62
- const newSubject = updated.subject || email.subject;
63
- if (!flags.json && oldSubject !== newSubject) {
64
- this.log(`\n Suggested subject:`);
65
- this.log(` ${chalk.red(`- ${oldSubject}`)}`);
66
- this.log(` ${chalk.green(`+ ${newSubject}`)}`);
175
+ if (flags.json) {
176
+ this.log(JSON.stringify(this.buildDiffPreview(email, updated, templateHtml), null, 2));
177
+ }
178
+ else {
179
+ this.showChangeSummary(email, updated, templateHtml);
67
180
  }
68
181
  if (!flags.yes) {
69
182
  const accepted = await confirm({
@@ -75,6 +188,8 @@ export default class Edit extends BaseCommand {
75
188
  return;
76
189
  }
77
190
  }
191
+ const oldSubject = email.subject;
192
+ const newSubject = updated.subject || email.subject;
78
193
  if (updated.subject)
79
194
  email.subject = updated.subject;
80
195
  if (updated.previewText)
@@ -91,6 +206,9 @@ export default class Edit extends BaseCommand {
91
206
  if (flags.json) {
92
207
  this.log(JSON.stringify({
93
208
  diff: {
209
+ previewText: updated.previewText && updated.previewText !== email.previewText
210
+ ? { new: updated.previewText, old: email.previewText }
211
+ : undefined,
94
212
  subject: oldSubject === newSubject
95
213
  ? undefined
96
214
  : { new: newSubject, old: oldSubject },
@@ -84,11 +84,18 @@ export default class Preview extends BaseCommand {
84
84
  app_url: yamlConfig.project?.url || 'https://yourapp.com', // eslint-disable-line camelcase
85
85
  product_name: yamlConfig.project?.name || 'YourApp', // eslint-disable-line camelcase
86
86
  };
87
+ const templateHtml = await loadTemplate(`${email.id}.html`);
87
88
  if (flags.send) {
88
- await this.sendTestEmail(templateId, flags.send, flags.json);
89
+ const rendered = templateHtml
90
+ ? renderTemplate(templateHtml, sampleData)
91
+ : '';
92
+ await this.sendTestEmail(email, rendered, {
93
+ domain: yamlConfig.project?.domain,
94
+ jsonOutput: flags.json,
95
+ toAddress: flags.send,
96
+ });
89
97
  return;
90
98
  }
91
- const templateHtml = await loadTemplate(`${email.id}.html`);
92
99
  if (flags.text) {
93
100
  await this.renderText(email, templateHtml, sampleData, flags.json);
94
101
  return;
@@ -123,21 +130,28 @@ export default class Preview extends BaseCommand {
123
130
  * Calls the API to send a test email to the specified address.
124
131
  * Before domain verification, tests send via the mailmodo.com domain.
125
132
  */
126
- async sendTestEmail(templateId, toAddress, jsonOutput) {
133
+ async sendTestEmail(email, html, opts) {
134
+ const { domain, jsonOutput, toAddress } = opts;
127
135
  await this.ensureAuth();
128
- const response = await this.withApiSpinner({ json: jsonOutput, text: ' Sending test email...' }, () => this.apiClient.post(`${API_ENDPOINTS.PREVIEW}/${templateId}/send`, {
136
+ const response = await this.withApiSpinner({ json: jsonOutput, text: ' Sending test email...' }, () => this.apiClient.post(`${API_ENDPOINTS.PREVIEW}/send`, {
137
+ domain,
138
+ html,
139
+ subject: email.subject,
129
140
  to: toAddress,
130
141
  }));
131
142
  if (!response.ok) {
132
143
  this.handleApiError(response);
133
144
  }
145
+ const { note, sentTo, sentVia, status } = response.data;
134
146
  if (jsonOutput) {
135
- this.log(JSON.stringify({ templateId, sentTo: toAddress, status: 'sent' }, null, 2));
147
+ this.log(JSON.stringify({ note, sentTo, sentVia, status }, null, 2));
136
148
  return;
137
149
  }
138
- this.log(`\n Sending test to ${chalk.cyan(toAddress)}...`);
139
- this.log(` ${chalk.dim('Note: If domain is not yet verified, test sends via mailmodo.com domain.')}`);
140
- this.log(` ${chalk.green('✓')} Sent.\n`);
150
+ this.log(`\n ${chalk.green('✓')} Test email sent to ${chalk.cyan(sentTo)} via ${chalk.cyan(sentVia)}.`);
151
+ if (note) {
152
+ this.log(` ${chalk.dim(note)}`);
153
+ }
154
+ this.log('');
141
155
  }
142
156
  /**
143
157
  * Starts a local HTTP server on PREVIEW_PORT to serve the rendered email
@@ -11,12 +11,14 @@ export declare const API_ENDPOINTS: Readonly<{
11
11
  DOMAIN: "/domain";
12
12
  DOMAIN_STATUS: "/domain/status";
13
13
  DOMAIN_VERIFY: "/domain/verify";
14
- EDIT: "/edit";
14
+ EDIT: "/email/edit";
15
15
  EVENTS: "/events";
16
16
  GENERATE: "/email/generate";
17
17
  LOGS: "/logs";
18
18
  PREVIEW: "/preview";
19
19
  SEQUENCES: "/sequences";
20
+ SEQUENCES_DEPLOY: "/sequences/deploy";
21
+ SEQUENCES_VALIDATE: "/sequences/validate";
20
22
  }>;
21
23
  export declare const LOGIN_URL = "https://app-vertex-debug.azurewebsites.net/login.html";
22
24
  export declare const DOCS_URL = "https://mailmodo.com/docs/cli";
@@ -17,12 +17,14 @@ export const API_ENDPOINTS = Object.freeze({
17
17
  DOMAIN: '/domain',
18
18
  DOMAIN_STATUS: '/domain/status',
19
19
  DOMAIN_VERIFY: '/domain/verify',
20
- EDIT: '/edit',
20
+ EDIT: '/email/edit',
21
21
  EVENTS: '/events',
22
22
  GENERATE: '/email/generate',
23
23
  LOGS: '/logs',
24
24
  PREVIEW: '/preview',
25
25
  SEQUENCES: '/sequences',
26
+ SEQUENCES_DEPLOY: '/sequences/deploy',
27
+ SEQUENCES_VALIDATE: '/sequences/validate',
26
28
  });
27
29
  const DEV_LOGIN_URL = 'https://app-vertex-debug.azurewebsites.net/login.html';
28
30
  // const PRODUCTION_LOGIN_URL = 'https://mailmodo.com/cli';
@@ -618,5 +618,5 @@
618
618
  ]
619
619
  }
620
620
  },
621
- "version": "0.0.19"
621
+ "version": "0.0.20-beta.pr22.32"
622
622
  }
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.19",
4
+ "version": "0.0.20-beta.pr22.32",
5
5
  "author": "provishalk",
6
6
  "bin": {
7
7
  "mailmodo": "bin/run.js"