@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.
- package/dist/commands/deploy/index.d.ts +12 -1
- package/dist/commands/deploy/index.js +180 -105
- package/dist/commands/edit/index.d.ts +10 -0
- package/dist/commands/edit/index.js +127 -9
- package/dist/commands/preview/index.js +22 -8
- package/dist/lib/constants.d.ts +3 -1
- package/dist/lib/constants.js +3 -1
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
|
@@ -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 (
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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:
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
this.log(
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
202
|
-
const allPassed =
|
|
276
|
+
const { dkim, dmarc, domainStatus, returnPath } = verify.data;
|
|
277
|
+
const allPassed = domainStatus === 'VERIFIED';
|
|
203
278
|
if (!jsonOutput) {
|
|
204
|
-
this.log(`
|
|
205
|
-
this.log(`
|
|
206
|
-
this.log(`
|
|
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(' ', ' ')
|
|
40
|
+
.replaceAll('&', '&')
|
|
41
|
+
.replaceAll('<', '<')
|
|
42
|
+
.replaceAll('>', '>')
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
this.
|
|
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
|
-
|
|
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(
|
|
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}
|
|
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({
|
|
147
|
+
this.log(JSON.stringify({ note, sentTo, sentVia, status }, null, 2));
|
|
136
148
|
return;
|
|
137
149
|
}
|
|
138
|
-
this.log(`\n
|
|
139
|
-
|
|
140
|
-
|
|
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
|
package/dist/lib/constants.d.ts
CHANGED
|
@@ -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";
|
package/dist/lib/constants.js
CHANGED
|
@@ -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';
|
package/oclif.manifest.json
CHANGED