@mailmodo/cli 0.0.53 → 0.0.54-beta.pr56.87
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/billing/index.js +9 -6
- package/dist/commands/deploy/index.d.ts +1 -32
- package/dist/commands/deploy/index.js +49 -304
- package/dist/commands/edit/index.js +1 -0
- package/dist/commands/init/index.js +1 -0
- package/dist/commands/login/index.js +17 -2
- package/dist/commands/sdk/index.d.ts +14 -0
- package/dist/commands/sdk/index.js +74 -0
- package/dist/commands/settings/index.js +6 -0
- package/dist/lib/api-client.d.ts +5 -0
- package/dist/lib/api-client.js +45 -0
- package/dist/lib/base-command.d.ts +24 -1
- package/dist/lib/base-command.js +84 -5
- package/dist/lib/constants.d.ts +5 -0
- package/dist/lib/constants.js +5 -0
- package/dist/lib/deploy/domain-setup.d.ts +8 -0
- package/dist/lib/deploy/domain-setup.js +80 -0
- package/dist/lib/deploy/missing-templates.d.ts +4 -0
- package/dist/lib/deploy/missing-templates.js +57 -0
- package/dist/lib/deploy/output.d.ts +5 -0
- package/dist/lib/deploy/output.js +61 -0
- package/dist/lib/deploy/payload.d.ts +41 -0
- package/dist/lib/deploy/payload.js +95 -0
- package/dist/lib/deploy/sequence-status.d.ts +3 -0
- package/dist/lib/deploy/sequence-status.js +56 -0
- package/dist/lib/deploy/types.d.ts +86 -0
- package/dist/lib/deploy/types.js +1 -0
- package/dist/lib/messages.d.ts +9 -0
- package/dist/lib/messages.js +9 -0
- package/oclif.manifest.json +101 -54
- package/package.json +1 -1
|
@@ -68,12 +68,14 @@ export default class Billing extends BaseCommand {
|
|
|
68
68
|
}
|
|
69
69
|
this.log(`\n ${chalk.bold('Stripe Checkout')} — add or update your payment method.`);
|
|
70
70
|
this.log(` ${chalk.dim(checkoutUrl)}\n`);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
if (!process.env.CI) {
|
|
72
|
+
try {
|
|
73
|
+
await open(checkoutUrl);
|
|
74
|
+
this.log(` ${INFO.BROWSER_OPENING}\n`);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
this.log(` ${INFO.BROWSER_OPEN_FAILED}\n`);
|
|
78
|
+
}
|
|
77
79
|
}
|
|
78
80
|
}
|
|
79
81
|
async showStatus(jsonOutput, statusOnly) {
|
|
@@ -176,6 +178,7 @@ export default class Billing extends BaseCommand {
|
|
|
176
178
|
return;
|
|
177
179
|
yamlConfig.project.monthlyCap = capBlocks;
|
|
178
180
|
await saveYaml(yamlConfig);
|
|
181
|
+
await this.syncYamlToServer();
|
|
179
182
|
}
|
|
180
183
|
pluralize(word, count) {
|
|
181
184
|
return count === 1 ? word : `${word}s`;
|
|
@@ -8,37 +8,6 @@ export default class Deploy extends BaseCommand {
|
|
|
8
8
|
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
9
|
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
10
|
};
|
|
11
|
-
private fetchDomainVerifyForDeploy;
|
|
12
11
|
run(): Promise<void>;
|
|
13
|
-
private
|
|
14
|
-
/**
|
|
15
|
-
* Calls `POST /sequences/{id}/status` with `{ status: "paused" }` and prints
|
|
16
|
-
* a confirmation-aware success/no-op message. Skips the prompt entirely when
|
|
17
|
-
* `--yes` is set so the command stays scriptable. `--json` always emits the
|
|
18
|
-
* raw server payload (sequenceId, status, alreadyInStatus).
|
|
19
|
-
*/
|
|
20
|
-
private pauseSequence;
|
|
21
|
-
/**
|
|
22
|
-
* Calls `POST /sequences/{id}/status` with `{ status: "active" }`. No prompt
|
|
23
|
-
* — resuming is the safe direction (it does not start sends that weren't
|
|
24
|
-
* already queued). `--json` always emits the raw server payload (sequenceId,
|
|
25
|
-
* status, alreadyInStatus).
|
|
26
|
-
*/
|
|
27
|
-
private resumeSequence;
|
|
28
|
-
private updateSequenceStatus;
|
|
29
|
-
private sequenceStatusPath;
|
|
30
|
-
private buildDeployPayload;
|
|
31
|
-
private resolveMonthlyCapForDeploy;
|
|
32
|
-
private mapEmailToPayload;
|
|
33
|
-
private buildBrandSection;
|
|
34
|
-
private buildProductSection;
|
|
35
|
-
private buildSenderSection;
|
|
36
|
-
private buildProjectPayload;
|
|
37
|
-
private confirmDeploy;
|
|
38
|
-
private ensureDomainReady;
|
|
39
|
-
private logPreDeploySummary;
|
|
40
|
-
private logDiff;
|
|
41
|
-
private logDeploySuccessInstructions;
|
|
42
|
-
private runDomainSetup;
|
|
43
|
-
private verifyDomain;
|
|
12
|
+
private makeCtx;
|
|
44
13
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Flags } from '@oclif/core';
|
|
2
|
-
import { confirm
|
|
3
|
-
import chalk from 'chalk';
|
|
2
|
+
import { confirm } from '@inquirer/prompts';
|
|
4
3
|
import { BaseCommand } from '../../lib/base-command.js';
|
|
5
|
-
import { API_ENDPOINTS
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
4
|
+
import { API_ENDPOINTS } from '../../lib/constants.js';
|
|
5
|
+
import { buildDeployPayload } from '../../lib/deploy/payload.js';
|
|
6
|
+
import { logDeploySuccessInstructions, logPreDeploySummary, } from '../../lib/deploy/output.js';
|
|
7
|
+
import { pauseSequence, resumeSequence, } from '../../lib/deploy/sequence-status.js';
|
|
8
|
+
import { ensureDomainReady, validateDeploySequence, } from '../../lib/deploy/domain-setup.js';
|
|
9
|
+
import { getMissingTemplateIds, handleMissingTemplates, } from '../../lib/deploy/missing-templates.js';
|
|
8
10
|
export default class Deploy extends BaseCommand {
|
|
9
11
|
static description = 'Deploy, pause, or resume an email sequence';
|
|
10
12
|
static examples = [
|
|
@@ -24,37 +26,45 @@ export default class Deploy extends BaseCommand {
|
|
|
24
26
|
exclusive: ['pause'],
|
|
25
27
|
}),
|
|
26
28
|
};
|
|
27
|
-
fetchDomainVerifyForDeploy(jsonOutput, domain) {
|
|
28
|
-
return this.withApiSpinner({ json: jsonOutput, text: ' Checking domain verification...' }, () => this.apiClient.get(API_ENDPOINTS.DOMAIN_VERIFY, {
|
|
29
|
-
domain: domain || '',
|
|
30
|
-
}));
|
|
31
|
-
}
|
|
32
29
|
async run() {
|
|
33
30
|
const { flags } = await this.parse(Deploy);
|
|
34
31
|
await this.ensureAuth();
|
|
32
|
+
const ctx = this.makeCtx();
|
|
35
33
|
const baseFlags = { json: flags.json, yes: flags.yes };
|
|
36
34
|
if (flags.pause) {
|
|
37
|
-
await
|
|
35
|
+
await pauseSequence(ctx, flags.pause, baseFlags);
|
|
38
36
|
return;
|
|
39
37
|
}
|
|
40
38
|
if (flags.resume) {
|
|
41
|
-
await
|
|
39
|
+
await resumeSequence(ctx, flags.resume, baseFlags);
|
|
42
40
|
return;
|
|
43
41
|
}
|
|
44
42
|
const yamlConfig = await this.ensureYaml();
|
|
45
|
-
const
|
|
46
|
-
if (
|
|
43
|
+
const missingIds = getMissingTemplateIds(yamlConfig);
|
|
44
|
+
if (missingIds.length > 0) {
|
|
45
|
+
await handleMissingTemplates(ctx, yamlConfig, missingIds, baseFlags);
|
|
47
46
|
return;
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
const confirmed = await this.confirmDeploy(yamlConfig, flags);
|
|
52
|
-
if (!confirmed)
|
|
47
|
+
}
|
|
48
|
+
const domainReady = await ensureDomainReady(ctx, yamlConfig, baseFlags);
|
|
49
|
+
if (!domainReady)
|
|
53
50
|
return;
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
const payload = await buildDeployPayload(ctx, yamlConfig);
|
|
52
|
+
const validateResult = await validateDeploySequence(ctx, payload, baseFlags);
|
|
53
|
+
logPreDeploySummary(ctx, yamlConfig, validateResult, flags.json);
|
|
54
|
+
if (!flags.yes) {
|
|
55
|
+
const proceed = await confirm({
|
|
56
|
+
default: true,
|
|
57
|
+
message: `Deploy ${yamlConfig.emails.length} emails?`,
|
|
58
|
+
});
|
|
59
|
+
if (!proceed) {
|
|
60
|
+
this.log('\n Deploy cancelled.\n');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
57
63
|
}
|
|
64
|
+
const response = await ctx.spinner(' Deploying email sequences...', flags.json, () => ctx.post(API_ENDPOINTS.SEQUENCES_DEPLOY, payload));
|
|
65
|
+
if (!response.ok)
|
|
66
|
+
ctx.onApiError(response);
|
|
67
|
+
await ctx.syncYaml();
|
|
58
68
|
if (flags.json) {
|
|
59
69
|
this.log(JSON.stringify({
|
|
60
70
|
deployed: response.data.deployed,
|
|
@@ -65,290 +75,25 @@ export default class Deploy extends BaseCommand {
|
|
|
65
75
|
}, null, 2));
|
|
66
76
|
return;
|
|
67
77
|
}
|
|
68
|
-
|
|
78
|
+
logDeploySuccessInstructions(ctx, response.data.sdkSnippet);
|
|
69
79
|
}
|
|
70
|
-
|
|
71
|
-
const response = await this.withApiSpinner({ json: flags.json, text: ' Validating sequence...' }, () => this.apiClient.post(API_ENDPOINTS.SEQUENCES_VALIDATE, payload));
|
|
72
|
-
if (!response.ok) {
|
|
73
|
-
if (response.data.error === 'senderDomainNotFound') {
|
|
74
|
-
this.error(ERRORS.DOMAIN_NOT_REGISTERED);
|
|
75
|
-
}
|
|
76
|
-
if (response.data.error === 'senderDomainNotVerified') {
|
|
77
|
-
this.error(ERRORS.DOMAIN_NOT_VERIFIED);
|
|
78
|
-
}
|
|
79
|
-
this.handleApiError(response);
|
|
80
|
-
}
|
|
81
|
-
return response.data;
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* Calls `POST /sequences/{id}/status` with `{ status: "paused" }` and prints
|
|
85
|
-
* a confirmation-aware success/no-op message. Skips the prompt entirely when
|
|
86
|
-
* `--yes` is set so the command stays scriptable. `--json` always emits the
|
|
87
|
-
* raw server payload (sequenceId, status, alreadyInStatus).
|
|
88
|
-
*/
|
|
89
|
-
async pauseSequence(sequenceId, flags) {
|
|
90
|
-
if (!flags.yes) {
|
|
91
|
-
const confirmed = await confirm({
|
|
92
|
-
default: false,
|
|
93
|
-
message: PROMPTS.PAUSE_CONFIRM,
|
|
94
|
-
});
|
|
95
|
-
if (!confirmed) {
|
|
96
|
-
this.log(`\n ${INFO.PAUSE_CANCELLED}\n`);
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
const data = await this.updateSequenceStatus(sequenceId, 'paused', flags, ' Pausing sequence...');
|
|
101
|
-
if (flags.json) {
|
|
102
|
-
this.log(JSON.stringify(data, null, 2));
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
const message = data.alreadyInStatus
|
|
106
|
-
? pauseAlready(data.sequenceId || sequenceId)
|
|
107
|
-
: pauseSuccess(data.sequenceId || sequenceId);
|
|
108
|
-
this.log(`\n ${message}\n`);
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* Calls `POST /sequences/{id}/status` with `{ status: "active" }`. No prompt
|
|
112
|
-
* — resuming is the safe direction (it does not start sends that weren't
|
|
113
|
-
* already queued). `--json` always emits the raw server payload (sequenceId,
|
|
114
|
-
* status, alreadyInStatus).
|
|
115
|
-
*/
|
|
116
|
-
async resumeSequence(sequenceId, flags) {
|
|
117
|
-
const data = await this.updateSequenceStatus(sequenceId, 'active', flags, ' Resuming sequence...');
|
|
118
|
-
if (flags.json) {
|
|
119
|
-
this.log(JSON.stringify(data, null, 2));
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
const message = data.alreadyInStatus
|
|
123
|
-
? resumeAlready(data.sequenceId || sequenceId)
|
|
124
|
-
: resumeSuccess(data.sequenceId || sequenceId);
|
|
125
|
-
this.log(`\n ${message}\n`);
|
|
126
|
-
}
|
|
127
|
-
async updateSequenceStatus(sequenceId, status, flags, spinnerText) {
|
|
128
|
-
const response = await this.withApiSpinner({ json: flags.json, text: spinnerText }, () => this.apiClient.post(this.sequenceStatusPath(sequenceId), { status }));
|
|
129
|
-
if (!response.ok) {
|
|
130
|
-
this.handleApiError(response);
|
|
131
|
-
}
|
|
132
|
-
return response.data;
|
|
133
|
-
}
|
|
134
|
-
sequenceStatusPath(sequenceId) {
|
|
135
|
-
return `${API_ENDPOINTS.SEQUENCES}/${encodeURIComponent(sequenceId)}/status`;
|
|
136
|
-
}
|
|
137
|
-
async buildDeployPayload(yamlConfig) {
|
|
138
|
-
const [emailsWithHtml, monthlyCap] = await Promise.all([
|
|
139
|
-
Promise.all(yamlConfig.emails.map(async (email) => {
|
|
140
|
-
const html = (await loadTemplate(`${email.id}.html`)) || '';
|
|
141
|
-
const plainHtml = (await loadTemplate(`${email.id}_plain.html`)) || html;
|
|
142
|
-
return { ...this.mapEmailToPayload(email), html, plainHtml };
|
|
143
|
-
})),
|
|
144
|
-
this.resolveMonthlyCapForDeploy(yamlConfig.project.monthlyCap),
|
|
145
|
-
]);
|
|
146
|
-
return {
|
|
147
|
-
...this.buildProjectPayload(yamlConfig.project, monthlyCap),
|
|
148
|
-
emails: emailsWithHtml,
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
async resolveMonthlyCapForDeploy(yamlMonthlyCap) {
|
|
152
|
-
if (yamlMonthlyCap !== undefined)
|
|
153
|
-
return yamlMonthlyCap;
|
|
154
|
-
const billingStatus = await this.fetchBillingStatus();
|
|
155
|
-
return billingStatus?.cap?.inBlocks ?? undefined;
|
|
156
|
-
}
|
|
157
|
-
mapEmailToPayload(email) {
|
|
158
|
-
return {
|
|
159
|
-
condition: email.condition || null,
|
|
160
|
-
ctaText: email.ctaText || '',
|
|
161
|
-
delay: email.delay,
|
|
162
|
-
goal: email.goal || '',
|
|
163
|
-
id: email.id,
|
|
164
|
-
isReminder: false,
|
|
165
|
-
previewText: email.previewText || '',
|
|
166
|
-
priority: 'medium',
|
|
167
|
-
subject: email.subject,
|
|
168
|
-
trigger: email.trigger,
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
buildBrandSection(project) {
|
|
80
|
+
makeCtx() {
|
|
172
81
|
return {
|
|
173
|
-
|
|
174
|
-
|
|
82
|
+
collectDomainInputs: (yaml, skip) => this.collectDomainSetupInputs(yaml, skip),
|
|
83
|
+
error: (msg) => this.error(msg),
|
|
84
|
+
exit: (code) => this.exit(code),
|
|
85
|
+
get: (path, params) => this.apiClient.get(path, params),
|
|
86
|
+
getBillingCap: async () => {
|
|
87
|
+
const s = await this.fetchBillingStatus();
|
|
88
|
+
return s?.cap?.inBlocks ?? undefined;
|
|
89
|
+
},
|
|
90
|
+
log: (msg) => this.log(msg),
|
|
91
|
+
onApiError: (r) => this.handleApiError(r),
|
|
92
|
+
post: (path, body) => this.apiClient.post(path, body),
|
|
93
|
+
registerDomainAndSave: (yaml, inputs, json) => this.registerDomain(yaml, inputs, json),
|
|
94
|
+
showDnsRecords: (records, url, json) => this.logDnsRecords(records, url, json),
|
|
95
|
+
spinner: (text, json, work) => this.withApiSpinner({ json, text }, work),
|
|
96
|
+
syncYaml: () => this.syncYamlToServer(),
|
|
175
97
|
};
|
|
176
98
|
}
|
|
177
|
-
buildProductSection(project) {
|
|
178
|
-
return {
|
|
179
|
-
businessType: project?.type || '',
|
|
180
|
-
description: project?.description || '',
|
|
181
|
-
pricingModel: project?.pricingModel || '',
|
|
182
|
-
productName: project?.name || '',
|
|
183
|
-
saasModel: project?.saasModel || '',
|
|
184
|
-
targetUser: project?.targetUser || '',
|
|
185
|
-
url: project?.url || '',
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
buildSenderSection(project) {
|
|
189
|
-
return {
|
|
190
|
-
address: project?.address || '',
|
|
191
|
-
domain: project?.domain || '',
|
|
192
|
-
fromEmail: project?.fromEmail || '',
|
|
193
|
-
fromName: project?.fromName || '',
|
|
194
|
-
replyTo: project?.replyTo || project?.fromEmail || '',
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
buildProjectPayload(project, monthlyCap) {
|
|
198
|
-
return {
|
|
199
|
-
brand: this.buildBrandSection(project),
|
|
200
|
-
emailStyle: project?.emailStyle || 'branded',
|
|
201
|
-
...(monthlyCap === undefined ? {} : { monthlyCap }),
|
|
202
|
-
product: this.buildProductSection(project),
|
|
203
|
-
senderDetails: this.buildSenderSection(project),
|
|
204
|
-
...(project?.webhookUrl ? { webhookUrl: project.webhookUrl } : {}),
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
async confirmDeploy(yamlConfig, flags) {
|
|
208
|
-
if (flags.yes)
|
|
209
|
-
return true;
|
|
210
|
-
const proceed = await confirm({
|
|
211
|
-
default: true,
|
|
212
|
-
message: `Deploy ${yamlConfig.emails.length} emails?`,
|
|
213
|
-
});
|
|
214
|
-
if (!proceed) {
|
|
215
|
-
this.log('\n Deploy cancelled.\n');
|
|
216
|
-
}
|
|
217
|
-
return proceed;
|
|
218
|
-
}
|
|
219
|
-
async ensureDomainReady(yamlConfig, flags) {
|
|
220
|
-
const domainVerify = await this.fetchDomainVerifyForDeploy(flags.json, yamlConfig.project?.domain);
|
|
221
|
-
if (domainVerify.ok && domainVerify.data?.domainStatus === 'VERIFIED') {
|
|
222
|
-
return true;
|
|
223
|
-
}
|
|
224
|
-
if (yamlConfig.project?.domain) {
|
|
225
|
-
if (!flags.json) {
|
|
226
|
-
this.log(`\n ${INFO.DOMAIN_PENDING_VERIFICATION}\n`);
|
|
227
|
-
}
|
|
228
|
-
return false;
|
|
229
|
-
}
|
|
230
|
-
if (!flags.json) {
|
|
231
|
-
this.log(`\n No sending domain set up yet.`);
|
|
232
|
-
this.log(` You need a verified domain before sending emails.`);
|
|
233
|
-
this.log(` This is a one-time setup. Takes about 5 minutes.\n`);
|
|
234
|
-
}
|
|
235
|
-
if (!flags.yes) {
|
|
236
|
-
const setupNow = await confirm({
|
|
237
|
-
default: true,
|
|
238
|
-
message: 'Set up your sending domain now?',
|
|
239
|
-
});
|
|
240
|
-
if (!setupNow) {
|
|
241
|
-
this.log(`\n ${INFO.SEQUENCES_NOT_DEPLOYED}`);
|
|
242
|
-
this.log(` Emails will not send until your domain is verified.`);
|
|
243
|
-
this.log(` ${INFO.DOMAIN_NOT_DEPLOYED_HINT}\n`);
|
|
244
|
-
return false;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
return this.runDomainSetup(yamlConfig, flags);
|
|
248
|
-
}
|
|
249
|
-
logPreDeploySummary(yamlConfig, validateResult, jsonOutput) {
|
|
250
|
-
if (jsonOutput)
|
|
251
|
-
return;
|
|
252
|
-
this.log(`\n ${chalk.green('✓')} Domain: ${yamlConfig.project?.domain || 'verified'}\n`);
|
|
253
|
-
if (!validateResult.existingDeployment || !validateResult.diff) {
|
|
254
|
-
this.log(` Deploying:`);
|
|
255
|
-
for (const email of yamlConfig.emails) {
|
|
256
|
-
this.log(` ${chalk.green('+')} ${email.id.padEnd(24)} ${email.trigger}`);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
else {
|
|
260
|
-
this.logDiff(validateResult.diff);
|
|
261
|
-
}
|
|
262
|
-
this.log('');
|
|
263
|
-
}
|
|
264
|
-
logDiff(diff) {
|
|
265
|
-
if (!diff.hasChanges) {
|
|
266
|
-
this.log(` No changes from last deployment.`);
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
this.log(` Changes vs. last deployment:`);
|
|
270
|
-
for (const email of diff.added) {
|
|
271
|
-
this.log(` ${chalk.green('+')} ${email.id.padEnd(24)} ${email.trigger || ''}`);
|
|
272
|
-
}
|
|
273
|
-
for (const email of diff.removed) {
|
|
274
|
-
this.log(` ${chalk.red('-')} ${email.id.padEnd(24)} ${email.trigger || ''}`);
|
|
275
|
-
}
|
|
276
|
-
for (const email of diff.modified) {
|
|
277
|
-
const fields = email.changedFields?.join(', ') || '';
|
|
278
|
-
this.log(` ${chalk.yellow('~')} ${email.id.padEnd(24)} ${fields}`);
|
|
279
|
-
}
|
|
280
|
-
if (diff.unchanged.length > 0) {
|
|
281
|
-
this.log(` ${chalk.dim(`∙ ${diff.unchanged.length} unchanged`)}`);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
logDeploySuccessInstructions(sdkSnippet) {
|
|
285
|
-
this.log(` ${chalk.green('Deployed.')} Emails are live.\n`);
|
|
286
|
-
this.log(` ${SEPARATOR}`);
|
|
287
|
-
this.log(` ${chalk.bold('ADD THIS TO YOUR APP (one-time only):')}`);
|
|
288
|
-
this.log(` ${SEPARATOR}\n`);
|
|
289
|
-
this.log(` ${chalk.cyan(sdkSnippet.install ?? 'npm install @mailmodo/sdk')}\n`);
|
|
290
|
-
this.log(` ${chalk.dim("import { track, identify } from '@mailmodo/sdk'")}\n`);
|
|
291
|
-
if (sdkSnippet.examples) {
|
|
292
|
-
this.log(` ${chalk.dim('// Example usage:')}`);
|
|
293
|
-
this.log(` ${chalk.dim(sdkSnippet.examples.track)}`);
|
|
294
|
-
this.log(` ${chalk.dim(sdkSnippet.examples.identify)}\n`);
|
|
295
|
-
}
|
|
296
|
-
const trackCalls = [...new Set(sdkSnippet.trackCalls ?? [])];
|
|
297
|
-
for (const call of trackCalls) {
|
|
298
|
-
this.log(` ${chalk.dim(call)}`);
|
|
299
|
-
}
|
|
300
|
-
if (trackCalls.length > 0)
|
|
301
|
-
this.log('');
|
|
302
|
-
const identifyCalls = [...new Set(sdkSnippet.identifyCalls ?? [])];
|
|
303
|
-
for (const call of identifyCalls) {
|
|
304
|
-
this.log(` ${chalk.dim(call)}`);
|
|
305
|
-
}
|
|
306
|
-
if (identifyCalls.length > 0)
|
|
307
|
-
this.log('');
|
|
308
|
-
this.log(` Full SDK docs: ${chalk.cyan('mailmodo.com/docs/sdk')}\n`);
|
|
309
|
-
this.log(` ${SEPARATOR}\n`);
|
|
310
|
-
}
|
|
311
|
-
async runDomainSetup(yamlConfig, flags) {
|
|
312
|
-
const inputs = await this.collectDomainSetupInputs(yamlConfig, flags.yes);
|
|
313
|
-
const { dnsRecords, dnsGuideUrl } = await this.registerDomain(yamlConfig, inputs, flags.json);
|
|
314
|
-
this.logDnsRecords(dnsRecords, dnsGuideUrl, flags.json);
|
|
315
|
-
if (flags.yes) {
|
|
316
|
-
return this.verifyDomain(flags.json, inputs.domain);
|
|
317
|
-
}
|
|
318
|
-
const action = await input({
|
|
319
|
-
default: '',
|
|
320
|
-
message: PROMPTS.ENTER_AFTER_RECORDS,
|
|
321
|
-
});
|
|
322
|
-
if (action.toLowerCase() === 'skip') {
|
|
323
|
-
this.log(`\n ${INFO.SEQUENCES_NOT_DEPLOYED}`);
|
|
324
|
-
this.log(` ${INFO.DOMAIN_NOT_DEPLOYED_HINT}\n`);
|
|
325
|
-
return false;
|
|
326
|
-
}
|
|
327
|
-
return this.verifyDomain(flags.json, inputs.domain);
|
|
328
|
-
}
|
|
329
|
-
async verifyDomain(jsonOutput, domain) {
|
|
330
|
-
const verify = await this.withApiSpinner({ json: jsonOutput, text: ' Checking DNS...' }, () => this.apiClient.get(API_ENDPOINTS.DOMAIN_VERIFY, {
|
|
331
|
-
domain,
|
|
332
|
-
}));
|
|
333
|
-
if (!verify.ok) {
|
|
334
|
-
this.handleApiError(verify);
|
|
335
|
-
}
|
|
336
|
-
const { dkim, dmarc, dnsGuideUrl, domainStatus, returnPath } = verify.data;
|
|
337
|
-
const allPassed = domainStatus === 'VERIFIED';
|
|
338
|
-
if (!jsonOutput) {
|
|
339
|
-
this.log(` DKIM ${dkim ? chalk.green('✓') : chalk.red('✗')}`);
|
|
340
|
-
this.log(` DMARC ${dmarc ? chalk.green('✓') : chalk.red('✗')}`);
|
|
341
|
-
this.log(` Return-Path ${returnPath ? chalk.green('✓') : chalk.red('✗')}`);
|
|
342
|
-
if (allPassed) {
|
|
343
|
-
this.log(`\n ${chalk.green('Domain verified.')} Continuing deploy...\n`);
|
|
344
|
-
}
|
|
345
|
-
else {
|
|
346
|
-
this.log(`\n ${INFO.DNS_RECORDS_FAILED}`);
|
|
347
|
-
this.log(`\n ${INFO.DNS_FIX_AND_VERIFY}`);
|
|
348
|
-
if (dnsGuideUrl)
|
|
349
|
-
this.log(` Help: ${chalk.cyan(dnsGuideUrl)}\n`);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
return allPassed;
|
|
353
|
-
}
|
|
354
99
|
}
|
|
@@ -125,6 +125,7 @@ export default class Edit extends BaseCommand {
|
|
|
125
125
|
if (updated.html) {
|
|
126
126
|
await saveTemplate(ctx.templateFilename, updated.html);
|
|
127
127
|
}
|
|
128
|
+
await this.syncYamlToServer();
|
|
128
129
|
}
|
|
129
130
|
logJsonResult(email, updated, oldSubject) {
|
|
130
131
|
this.log(JSON.stringify({
|
|
@@ -27,11 +27,14 @@ 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);
|
|
30
32
|
if (flags.json) {
|
|
31
33
|
this.log(JSON.stringify({
|
|
32
34
|
email: existing.email ?? null,
|
|
33
35
|
status: 'already_logged_in',
|
|
34
36
|
totalFreeRemaining: existing.totalFreeRemaining ?? null,
|
|
37
|
+
yamlRestored,
|
|
35
38
|
}, null, 2));
|
|
36
39
|
return;
|
|
37
40
|
}
|
|
@@ -40,7 +43,12 @@ export default class Login extends BaseCommand {
|
|
|
40
43
|
? chalk.green(existing.email.trim())
|
|
41
44
|
: chalk.dim('(unknown)');
|
|
42
45
|
this.log(` Email: ${emailDisplay}\n`);
|
|
43
|
-
|
|
46
|
+
if (yamlRestored) {
|
|
47
|
+
this.log(` ${INFO.YAML_RESTORED_ON_LOGIN}`);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
this.log(` ${chalk.dim('1.')} Run ${chalk.cyan('mailmodo init')} to generate an email sequence.`);
|
|
51
|
+
}
|
|
44
52
|
this.log(` ${chalk.dim('2.')} Run ${chalk.cyan('mailmodo logout')} to log in with another account.\n`);
|
|
45
53
|
return;
|
|
46
54
|
}
|
|
@@ -79,6 +87,7 @@ export default class Login extends BaseCommand {
|
|
|
79
87
|
email,
|
|
80
88
|
totalFreeRemaining,
|
|
81
89
|
});
|
|
90
|
+
const yamlRestored = await this.recoverYamlAfterLogin(client);
|
|
82
91
|
if (flags.json) {
|
|
83
92
|
this.log(JSON.stringify({
|
|
84
93
|
email,
|
|
@@ -86,6 +95,7 @@ export default class Login extends BaseCommand {
|
|
|
86
95
|
totalFreeRemaining,
|
|
87
96
|
paidEmailsRemaining,
|
|
88
97
|
status: 'authenticated',
|
|
98
|
+
yamlRestored,
|
|
89
99
|
}, null, 2));
|
|
90
100
|
return;
|
|
91
101
|
}
|
|
@@ -97,6 +107,11 @@ export default class Login extends BaseCommand {
|
|
|
97
107
|
if (plan === 'paid') {
|
|
98
108
|
this.log(` Current paid block: ${chalk.cyan(String(paidEmailsRemaining))} emails remaining\n`);
|
|
99
109
|
}
|
|
100
|
-
|
|
110
|
+
if (yamlRestored) {
|
|
111
|
+
this.log(` ${INFO.YAML_RESTORED_ON_LOGIN}\n`);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
this.log(` Next: Run ${chalk.cyan("'mailmodo init'")} to generate your email sequences.\n`);
|
|
115
|
+
}
|
|
101
116
|
}
|
|
102
117
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { BaseCommand } from '../../lib/base-command.js';
|
|
2
|
+
export default class Sdk extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
'sequence-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
|
+
};
|
|
10
|
+
run(): Promise<void>;
|
|
11
|
+
private renderSnippets;
|
|
12
|
+
private renderSequenceBlock;
|
|
13
|
+
private renderCallBlock;
|
|
14
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { BaseCommand } from '../../lib/base-command.js';
|
|
4
|
+
import { API_ENDPOINTS, SDK_IMPORT_SNIPPET, SDK_INSTALL_COMMAND, } from '../../lib/constants.js';
|
|
5
|
+
import { SEPARATOR } from '../../lib/messages.js';
|
|
6
|
+
export default class Sdk extends BaseCommand {
|
|
7
|
+
static description = 'Show the SDK track() / identify() reference for deployed sequences';
|
|
8
|
+
static examples = [
|
|
9
|
+
'<%= config.bin %> sdk',
|
|
10
|
+
'<%= config.bin %> sdk --sequence-id a1b2c3d4',
|
|
11
|
+
'<%= config.bin %> sdk --json',
|
|
12
|
+
];
|
|
13
|
+
static flags = {
|
|
14
|
+
...BaseCommand.baseFlags,
|
|
15
|
+
'sequence-id': Flags.string({
|
|
16
|
+
description: 'Limit output to a single active sequence by ID (default: all active sequences)',
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
async run() {
|
|
20
|
+
const { flags } = await this.parse(Sdk);
|
|
21
|
+
await this.ensureAuth();
|
|
22
|
+
const params = flags['sequence-id']
|
|
23
|
+
? { sequenceId: flags['sequence-id'] }
|
|
24
|
+
: undefined;
|
|
25
|
+
const response = await this.withApiSpinner({ json: flags.json, text: ' Loading SDK reference...' }, () => this.apiClient.get(API_ENDPOINTS.SEQUENCES_SDK, params));
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
this.handleApiError(response);
|
|
28
|
+
}
|
|
29
|
+
if (flags.json) {
|
|
30
|
+
this.log(JSON.stringify(response.data, null, 2));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
this.renderSnippets(response.data);
|
|
34
|
+
}
|
|
35
|
+
renderSnippets(data) {
|
|
36
|
+
const snippets = data.sdkSnippets ?? [];
|
|
37
|
+
if (snippets.length === 0) {
|
|
38
|
+
this.log(`\n ${chalk.dim('No active deployed sequences.')}`);
|
|
39
|
+
this.log(` Run ${chalk.cyan('mailmodo deploy')} to deploy one.\n`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
this.log(`\n ${chalk.bold(String(snippets.length))} active ${snippets.length === 1 ? 'sequence' : 'sequences'}:\n`);
|
|
43
|
+
this.log(` ${SEPARATOR}`);
|
|
44
|
+
this.log(` ${chalk.bold('SDK EVENT REFERENCE')}`);
|
|
45
|
+
this.log(` ${SEPARATOR}\n`);
|
|
46
|
+
this.log(` ${chalk.cyan(SDK_INSTALL_COMMAND)}\n`);
|
|
47
|
+
this.log(` ${chalk.dim(SDK_IMPORT_SNIPPET)}\n`);
|
|
48
|
+
for (const [index, snippet] of snippets.entries()) {
|
|
49
|
+
this.renderSequenceBlock(snippet);
|
|
50
|
+
if (index < snippets.length - 1)
|
|
51
|
+
this.log('');
|
|
52
|
+
}
|
|
53
|
+
this.log(` ${SEPARATOR}\n`);
|
|
54
|
+
}
|
|
55
|
+
renderSequenceBlock(snippet) {
|
|
56
|
+
const productName = snippet.productName || 'Unnamed sequence';
|
|
57
|
+
this.log(` ${chalk.bold(productName)} ${chalk.dim(`(${snippet.sequenceId})`)}`);
|
|
58
|
+
const trackCalls = [...new Set(snippet.sdkSnippet?.trackCalls ?? [])];
|
|
59
|
+
const identifyCalls = [...new Set(snippet.sdkSnippet?.identifyCalls ?? [])];
|
|
60
|
+
this.renderCallBlock('// track() calls', trackCalls);
|
|
61
|
+
this.renderCallBlock('// identify() calls', identifyCalls);
|
|
62
|
+
if (trackCalls.length === 0 && identifyCalls.length === 0) {
|
|
63
|
+
this.log(` ${chalk.dim('No track() or identify() calls available.')}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
renderCallBlock(label, calls) {
|
|
67
|
+
if (calls.length === 0)
|
|
68
|
+
return;
|
|
69
|
+
this.log(` ${chalk.dim(label)}`);
|
|
70
|
+
for (const call of calls) {
|
|
71
|
+
this.log(` ${chalk.dim(call)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -50,6 +50,7 @@ export default class Settings extends BaseCommand {
|
|
|
50
50
|
if (capFromApi !== null) {
|
|
51
51
|
yamlConfig.project.monthlyCap = capFromApi;
|
|
52
52
|
await saveYaml(yamlConfig);
|
|
53
|
+
await this.syncYamlToServer();
|
|
53
54
|
}
|
|
54
55
|
if (flags.json) {
|
|
55
56
|
this.log(JSON.stringify({ settings: yamlConfig.project }, null, 2));
|
|
@@ -83,6 +84,7 @@ export default class Settings extends BaseCommand {
|
|
|
83
84
|
}
|
|
84
85
|
project[propKey] = value;
|
|
85
86
|
await saveYaml(yamlConfig);
|
|
87
|
+
await this.syncYamlToServer();
|
|
86
88
|
if (isJson) {
|
|
87
89
|
this.log(JSON.stringify({ [propKey]: value, status: 'updated' }, null, 2));
|
|
88
90
|
return;
|
|
@@ -104,6 +106,7 @@ export default class Settings extends BaseCommand {
|
|
|
104
106
|
const data = await this.applyBillingCap({ cap: parsed, json: isJson });
|
|
105
107
|
yamlConfig.project.monthlyCap = data.capBlocks;
|
|
106
108
|
await saveYaml(yamlConfig);
|
|
109
|
+
await this.syncYamlToServer();
|
|
107
110
|
if (isJson) {
|
|
108
111
|
this.log(JSON.stringify({ monthlyCap: data.capBlocks, status: 'updated' }, null, 2));
|
|
109
112
|
return;
|
|
@@ -207,6 +210,7 @@ export default class Settings extends BaseCommand {
|
|
|
207
210
|
});
|
|
208
211
|
project.emailStyle = style;
|
|
209
212
|
await saveYaml(yamlConfig);
|
|
213
|
+
await this.syncYamlToServer();
|
|
210
214
|
this.log(`\n ${chalk.green('✓')} email_style updated to ${chalk.cyan(style)}`);
|
|
211
215
|
this.log(` ${INFO.DEPLOY_TO_APPLY}\n`);
|
|
212
216
|
return;
|
|
@@ -216,6 +220,7 @@ export default class Settings extends BaseCommand {
|
|
|
216
220
|
});
|
|
217
221
|
project[editPropKey] = newValue;
|
|
218
222
|
await saveYaml(yamlConfig);
|
|
223
|
+
await this.syncYamlToServer();
|
|
219
224
|
this.log(`\n ${chalk.green('✓')} Updated. ${INFO.DEPLOY_TO_APPLY}\n`);
|
|
220
225
|
}
|
|
221
226
|
/**
|
|
@@ -281,6 +286,7 @@ export default class Settings extends BaseCommand {
|
|
|
281
286
|
yamlConfig.project.logoUrl = response.data?.url || '';
|
|
282
287
|
yamlConfig.project.logoFile = logoPath;
|
|
283
288
|
await saveYaml(yamlConfig);
|
|
289
|
+
await this.syncYamlToServer();
|
|
284
290
|
this.log(`\n Logo uploaded and hosted at:`);
|
|
285
291
|
this.log(` ${chalk.cyan(String(response.data?.url))}`);
|
|
286
292
|
this.log(` Run ${chalk.cyan("'mailmodo deploy'")} to apply to all branded emails.\n`);
|