@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
package/dist/lib/api-client.d.ts
CHANGED
|
@@ -46,6 +46,11 @@ export declare class ApiClient {
|
|
|
46
46
|
* Bearer auth as other requests; does not parse JSON.
|
|
47
47
|
*/
|
|
48
48
|
getFile(url: string): Promise<FileFetchResult>;
|
|
49
|
+
/**
|
|
50
|
+
* GET an endpoint and return the raw response body as a string (e.g. YAML files).
|
|
51
|
+
* Uses Bearer auth; does not parse JSON.
|
|
52
|
+
*/
|
|
53
|
+
getRawText(path: string): Promise<ApiResponse<string>>;
|
|
49
54
|
/**
|
|
50
55
|
* GET an external URL (e.g. blob storage) without auth headers.
|
|
51
56
|
*/
|
package/dist/lib/api-client.js
CHANGED
|
@@ -135,6 +135,51 @@ export class ApiClient {
|
|
|
135
135
|
async getFile(url) {
|
|
136
136
|
return fetchFileWithBearerAuth(url, this.apiKey);
|
|
137
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* GET an endpoint and return the raw response body as a string (e.g. YAML files).
|
|
140
|
+
* Uses Bearer auth; does not parse JSON.
|
|
141
|
+
*/
|
|
142
|
+
async getRawText(path) {
|
|
143
|
+
const url = this.resolveUrl(path);
|
|
144
|
+
const debug = this.requestDebug(url);
|
|
145
|
+
try {
|
|
146
|
+
const response = await fetch(url.toString(), {
|
|
147
|
+
headers: {
|
|
148
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
149
|
+
'User-Agent': '@mailmodo/cli',
|
|
150
|
+
},
|
|
151
|
+
method: 'GET',
|
|
152
|
+
});
|
|
153
|
+
const text = await response.text().catch(() => '');
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
return {
|
|
156
|
+
data: '',
|
|
157
|
+
debug: {
|
|
158
|
+
...debug,
|
|
159
|
+
...(text ? { responseSummary: text.slice(0, 200) } : {}),
|
|
160
|
+
},
|
|
161
|
+
error: text || `Request failed with status ${response.status}`,
|
|
162
|
+
ok: false,
|
|
163
|
+
status: response.status,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return { data: text, ok: true, status: response.status };
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
const err = error;
|
|
170
|
+
const isConnectionError = err?.cause?.code === 'ECONNREFUSED' || err?.cause?.code === 'ENOTFOUND';
|
|
171
|
+
const causeCode = err?.cause?.code;
|
|
172
|
+
return {
|
|
173
|
+
data: '',
|
|
174
|
+
debug: { ...debug, ...(causeCode ? { causeCode } : {}) },
|
|
175
|
+
error: isConnectionError
|
|
176
|
+
? 'Cannot connect to Mailmodo API. The API service may not be available yet.'
|
|
177
|
+
: err?.message || 'An unexpected network error occurred.',
|
|
178
|
+
ok: false,
|
|
179
|
+
status: 0,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
138
183
|
/**
|
|
139
184
|
* GET an external URL (e.g. blob storage) without auth headers.
|
|
140
185
|
*/
|
|
@@ -60,12 +60,35 @@ export declare abstract class BaseCommand extends Command {
|
|
|
60
60
|
}, work: () => Promise<T>): Promise<T>;
|
|
61
61
|
/**
|
|
62
62
|
* Loads and returns the mailmodo.yaml configuration from the current directory.
|
|
63
|
-
*
|
|
63
|
+
* If the file is not found locally and the API client is available, attempts to
|
|
64
|
+
* restore it from the server. Exits with an error if the file cannot be found
|
|
65
|
+
* or restored, directing the user to run init.
|
|
64
66
|
*
|
|
65
67
|
* @returns {Promise<MailmodoYaml>} The parsed mailmodo.yaml containing project
|
|
66
68
|
* settings and all email sequence definitions.
|
|
67
69
|
*/
|
|
68
70
|
protected ensureYaml(): Promise<MailmodoYaml>;
|
|
71
|
+
/**
|
|
72
|
+
* Attempts to fetch mailmodo.yaml from the server and save it locally.
|
|
73
|
+
* Returns null silently on any failure so callers can fall through to an error.
|
|
74
|
+
*/
|
|
75
|
+
private restoreYamlFromServer;
|
|
76
|
+
/**
|
|
77
|
+
* If `mailmodo.yaml` is absent from the current directory, attempts to restore
|
|
78
|
+
* it from the server using the given client. Returns `true` if the file was
|
|
79
|
+
* successfully written, `false` otherwise (file already present, server 404,
|
|
80
|
+
* or any network error). Silent — never throws.
|
|
81
|
+
*
|
|
82
|
+
* Used by `mailmodo login` right after the API key is validated so a returning
|
|
83
|
+
* user automatically gets their config back without having to run `init` again.
|
|
84
|
+
*/
|
|
85
|
+
protected recoverYamlAfterLogin(client: ApiClient): Promise<boolean>;
|
|
86
|
+
/**
|
|
87
|
+
* Uploads the current local mailmodo.yaml to the server as a backup.
|
|
88
|
+
* Best-effort: silently ignores all errors so the originating command
|
|
89
|
+
* always succeeds regardless of sync failures.
|
|
90
|
+
*/
|
|
91
|
+
protected syncYamlToServer(): Promise<void>;
|
|
69
92
|
/**
|
|
70
93
|
* Handles a failed API response by mapping HTTP status codes to
|
|
71
94
|
* user-friendly error messages and exiting the process.
|
package/dist/lib/base-command.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
1
4
|
import { input } from '@inquirer/prompts';
|
|
2
5
|
import { Command, Flags } from '@oclif/core';
|
|
3
6
|
import chalk from 'chalk';
|
|
4
7
|
import ora from 'ora';
|
|
5
8
|
import { ApiClient } from './api-client.js';
|
|
6
9
|
import { loadConfig } from './config.js';
|
|
7
|
-
import { API_ENDPOINTS, IS_DEV_MODE } from './constants.js';
|
|
10
|
+
import { API_ENDPOINTS, IS_DEV_MODE, YAML_FILE } from './constants.js';
|
|
8
11
|
import { ERRORS, INFO, PROMPTS, quotaExhaustedMessage, recordLabel, VALIDATION, } from './messages.js';
|
|
9
12
|
import { loadYaml, saveYaml } from './yaml-config.js';
|
|
10
13
|
export const FREE_TIER = 'free';
|
|
@@ -76,17 +79,92 @@ export class BaseCommand extends Command {
|
|
|
76
79
|
}
|
|
77
80
|
/**
|
|
78
81
|
* Loads and returns the mailmodo.yaml configuration from the current directory.
|
|
79
|
-
*
|
|
82
|
+
* If the file is not found locally and the API client is available, attempts to
|
|
83
|
+
* restore it from the server. Exits with an error if the file cannot be found
|
|
84
|
+
* or restored, directing the user to run init.
|
|
80
85
|
*
|
|
81
86
|
* @returns {Promise<MailmodoYaml>} The parsed mailmodo.yaml containing project
|
|
82
87
|
* settings and all email sequence definitions.
|
|
83
88
|
*/
|
|
84
89
|
async ensureYaml() {
|
|
85
90
|
const config = await loadYaml();
|
|
86
|
-
if (
|
|
87
|
-
|
|
91
|
+
if (config)
|
|
92
|
+
return config;
|
|
93
|
+
const restored = await this.restoreYamlFromServer();
|
|
94
|
+
if (restored)
|
|
95
|
+
return restored;
|
|
96
|
+
this.error(ERRORS.NO_YAML);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Attempts to fetch mailmodo.yaml from the server and save it locally.
|
|
100
|
+
* Returns null silently on any failure so callers can fall through to an error.
|
|
101
|
+
*/
|
|
102
|
+
async restoreYamlFromServer() {
|
|
103
|
+
if (!this.apiClient)
|
|
104
|
+
return null;
|
|
105
|
+
try {
|
|
106
|
+
const response = await this.apiClient.getRawText(API_ENDPOINTS.ASSETS_YAML);
|
|
107
|
+
if (!response.ok || !response.data)
|
|
108
|
+
return null;
|
|
109
|
+
const filePath = join(process.cwd(), YAML_FILE);
|
|
110
|
+
await writeFile(filePath, response.data);
|
|
111
|
+
this.log(INFO.YAML_RESTORED_FROM_SERVER);
|
|
112
|
+
return loadYaml();
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* If `mailmodo.yaml` is absent from the current directory, attempts to restore
|
|
120
|
+
* it from the server using the given client. Returns `true` if the file was
|
|
121
|
+
* successfully written, `false` otherwise (file already present, server 404,
|
|
122
|
+
* or any network error). Silent — never throws.
|
|
123
|
+
*
|
|
124
|
+
* Used by `mailmodo login` right after the API key is validated so a returning
|
|
125
|
+
* user automatically gets their config back without having to run `init` again.
|
|
126
|
+
*/
|
|
127
|
+
async recoverYamlAfterLogin(client) {
|
|
128
|
+
if (existsSync(join(process.cwd(), YAML_FILE)))
|
|
129
|
+
return false;
|
|
130
|
+
try {
|
|
131
|
+
const response = await client.getRawText(API_ENDPOINTS.ASSETS_YAML);
|
|
132
|
+
if (!response.ok || !response.data)
|
|
133
|
+
return false;
|
|
134
|
+
await writeFile(join(process.cwd(), YAML_FILE), response.data);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Uploads the current local mailmodo.yaml to the server as a backup.
|
|
143
|
+
* Best-effort: silently ignores all errors so the originating command
|
|
144
|
+
* always succeeds regardless of sync failures.
|
|
145
|
+
*/
|
|
146
|
+
async syncYamlToServer() {
|
|
147
|
+
try {
|
|
148
|
+
let client = this.apiClient;
|
|
149
|
+
if (!client) {
|
|
150
|
+
const envKey = process.env.MAILMODO_API_KEY;
|
|
151
|
+
const apiKey = envKey ?? (await loadConfig())?.apiKey;
|
|
152
|
+
if (!apiKey)
|
|
153
|
+
return;
|
|
154
|
+
client = new ApiClient(apiKey);
|
|
155
|
+
}
|
|
156
|
+
const filePath = join(process.cwd(), YAML_FILE);
|
|
157
|
+
if (!existsSync(filePath))
|
|
158
|
+
return;
|
|
159
|
+
const content = await readFile(filePath, 'utf8');
|
|
160
|
+
const blob = new Blob([content], { type: 'application/yaml' });
|
|
161
|
+
const formData = new FormData();
|
|
162
|
+
formData.append('yaml', blob, YAML_FILE);
|
|
163
|
+
await client.postFormData(API_ENDPOINTS.ASSETS_YAML, formData);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Silently ignore — local file remains authoritative
|
|
88
167
|
}
|
|
89
|
-
return config;
|
|
90
168
|
}
|
|
91
169
|
/**
|
|
92
170
|
* Handles a failed API response by mapping HTTP status codes to
|
|
@@ -276,6 +354,7 @@ export class BaseCommand extends Command {
|
|
|
276
354
|
if (inputs.replyTo)
|
|
277
355
|
yamlConfig.project.replyTo = inputs.replyTo;
|
|
278
356
|
await saveYaml(yamlConfig);
|
|
357
|
+
await this.syncYamlToServer();
|
|
279
358
|
return {
|
|
280
359
|
dnsGuideUrl: response.data?.dnsGuideUrl,
|
|
281
360
|
dnsRecords: response.data?.dnsRecords || [],
|
package/dist/lib/constants.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export declare const API_ENDPOINTS: Readonly<{
|
|
|
9
9
|
ANALYTICS: "/analytics";
|
|
10
10
|
ANALYZE: "/analyze";
|
|
11
11
|
ASSETS_LOGO: "/assets/logo";
|
|
12
|
+
ASSETS_YAML: "/assets/yaml";
|
|
12
13
|
AUTH_VALIDATE: "/auth/validate";
|
|
13
14
|
BILLING_CAP: "/billing/cap";
|
|
14
15
|
BILLING_CHECKOUT: "/billing/checkout";
|
|
@@ -26,6 +27,7 @@ export declare const API_ENDPOINTS: Readonly<{
|
|
|
26
27
|
PREVIEW: "/preview";
|
|
27
28
|
SEQUENCES: "/sequences";
|
|
28
29
|
SEQUENCES_DEPLOY: "/sequences/deploy";
|
|
30
|
+
SEQUENCES_SDK: "/sequences/sdk";
|
|
29
31
|
SEQUENCES_VALIDATE: "/sequences/validate";
|
|
30
32
|
}>;
|
|
31
33
|
export declare const LOGIN_URL = "https://app-vertex-debug.azurewebsites.net/signup.html";
|
|
@@ -33,3 +35,6 @@ export declare const PREVIEW_PORT = 3421;
|
|
|
33
35
|
export declare const DEFAULT_BRAND_COLOR = "#1A56DB";
|
|
34
36
|
export declare const TEMPLATES_DIR = "mailmodo";
|
|
35
37
|
export declare const YAML_FILE = "mailmodo.yaml";
|
|
38
|
+
export declare const SDK_PACKAGE_NAME = "@mailmodo/sdk";
|
|
39
|
+
export declare const SDK_INSTALL_COMMAND = "npm install @mailmodo/sdk";
|
|
40
|
+
export declare const SDK_IMPORT_SNIPPET = "import { track, identify } from '@mailmodo/sdk'";
|
package/dist/lib/constants.js
CHANGED
|
@@ -15,6 +15,7 @@ export const API_ENDPOINTS = Object.freeze({
|
|
|
15
15
|
ANALYTICS: '/analytics',
|
|
16
16
|
ANALYZE: '/analyze',
|
|
17
17
|
ASSETS_LOGO: '/assets/logo',
|
|
18
|
+
ASSETS_YAML: '/assets/yaml',
|
|
18
19
|
AUTH_VALIDATE: '/auth/validate',
|
|
19
20
|
BILLING_CAP: '/billing/cap',
|
|
20
21
|
BILLING_CHECKOUT: '/billing/checkout',
|
|
@@ -32,6 +33,7 @@ export const API_ENDPOINTS = Object.freeze({
|
|
|
32
33
|
PREVIEW: '/preview',
|
|
33
34
|
SEQUENCES: '/sequences',
|
|
34
35
|
SEQUENCES_DEPLOY: '/sequences/deploy',
|
|
36
|
+
SEQUENCES_SDK: '/sequences/sdk',
|
|
35
37
|
SEQUENCES_VALIDATE: '/sequences/validate',
|
|
36
38
|
});
|
|
37
39
|
const DEV_LOGIN_URL = 'https://app-vertex-debug.azurewebsites.net/signup.html';
|
|
@@ -44,3 +46,6 @@ export const PREVIEW_PORT = 3421;
|
|
|
44
46
|
export const DEFAULT_BRAND_COLOR = '#1A56DB';
|
|
45
47
|
export const TEMPLATES_DIR = 'mailmodo';
|
|
46
48
|
export const YAML_FILE = 'mailmodo.yaml';
|
|
49
|
+
export const SDK_PACKAGE_NAME = '@mailmodo/sdk';
|
|
50
|
+
export const SDK_INSTALL_COMMAND = `npm install ${SDK_PACKAGE_NAME}`;
|
|
51
|
+
export const SDK_IMPORT_SNIPPET = `import { track, identify } from '${SDK_PACKAGE_NAME}'`;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { MailmodoYaml } from '../yaml-config.js';
|
|
2
|
+
import type { DeployCtx, DeployFlags, ValidateResponse } from './types.js';
|
|
3
|
+
export declare function validateDeploySequence(ctx: DeployCtx, payload: object, flags: {
|
|
4
|
+
json: boolean;
|
|
5
|
+
}): Promise<ValidateResponse>;
|
|
6
|
+
export declare function verifyDomain(ctx: DeployCtx, jsonOutput: boolean, domain: string): Promise<boolean>;
|
|
7
|
+
export declare function runDomainSetup(ctx: DeployCtx, yamlConfig: MailmodoYaml, flags: DeployFlags): Promise<boolean>;
|
|
8
|
+
export declare function ensureDomainReady(ctx: DeployCtx, yamlConfig: MailmodoYaml, flags: DeployFlags): Promise<boolean>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { confirm, input } from '@inquirer/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { API_ENDPOINTS } from '../constants.js';
|
|
4
|
+
import { ERRORS, INFO, PROMPTS } from '../messages.js';
|
|
5
|
+
export async function validateDeploySequence(ctx, payload, flags) {
|
|
6
|
+
const res = await ctx.spinner(' Validating sequence...', flags.json, () => ctx.post(API_ENDPOINTS.SEQUENCES_VALIDATE, payload));
|
|
7
|
+
if (!res.ok) {
|
|
8
|
+
if (res.data.error === 'senderDomainNotFound')
|
|
9
|
+
ctx.error(ERRORS.DOMAIN_NOT_REGISTERED);
|
|
10
|
+
if (res.data.error === 'senderDomainNotVerified')
|
|
11
|
+
ctx.error(ERRORS.DOMAIN_NOT_VERIFIED);
|
|
12
|
+
ctx.onApiError(res);
|
|
13
|
+
}
|
|
14
|
+
return res.data;
|
|
15
|
+
}
|
|
16
|
+
export async function verifyDomain(ctx, jsonOutput, domain) {
|
|
17
|
+
const res = await ctx.spinner(' Checking DNS...', jsonOutput, () => ctx.get(API_ENDPOINTS.DOMAIN_VERIFY, { domain }));
|
|
18
|
+
if (!res.ok)
|
|
19
|
+
ctx.onApiError(res);
|
|
20
|
+
const { dkim, dmarc, dnsGuideUrl, domainStatus, returnPath } = res.data;
|
|
21
|
+
const allPassed = domainStatus === 'VERIFIED';
|
|
22
|
+
if (!jsonOutput) {
|
|
23
|
+
ctx.log(` DKIM ${dkim ? chalk.green('✓') : chalk.red('✗')}`);
|
|
24
|
+
ctx.log(` DMARC ${dmarc ? chalk.green('✓') : chalk.red('✗')}`);
|
|
25
|
+
ctx.log(` Return-Path ${returnPath ? chalk.green('✓') : chalk.red('✗')}`);
|
|
26
|
+
if (allPassed) {
|
|
27
|
+
ctx.log(`\n ${chalk.green('Domain verified.')} Continuing deploy...\n`);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
ctx.log(`\n ${INFO.DNS_RECORDS_FAILED}\n ${INFO.DNS_FIX_AND_VERIFY}`);
|
|
31
|
+
if (dnsGuideUrl)
|
|
32
|
+
ctx.log(` Help: ${chalk.cyan(dnsGuideUrl)}\n`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return allPassed;
|
|
36
|
+
}
|
|
37
|
+
export async function runDomainSetup(ctx, yamlConfig, flags) {
|
|
38
|
+
const inputs = await ctx.collectDomainInputs(yamlConfig, flags.yes);
|
|
39
|
+
const { dnsRecords, dnsGuideUrl } = await ctx.registerDomainAndSave(yamlConfig, inputs, flags.json);
|
|
40
|
+
ctx.showDnsRecords(dnsRecords, dnsGuideUrl, flags.json);
|
|
41
|
+
if (flags.yes)
|
|
42
|
+
return verifyDomain(ctx, flags.json, inputs.domain);
|
|
43
|
+
const action = await input({
|
|
44
|
+
default: '',
|
|
45
|
+
message: PROMPTS.ENTER_AFTER_RECORDS,
|
|
46
|
+
});
|
|
47
|
+
if (action.toLowerCase() === 'skip') {
|
|
48
|
+
ctx.log(`\n ${INFO.SEQUENCES_NOT_DEPLOYED}\n ${INFO.DOMAIN_NOT_DEPLOYED_HINT}\n`);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return verifyDomain(ctx, flags.json, inputs.domain);
|
|
52
|
+
}
|
|
53
|
+
export async function ensureDomainReady(ctx, yamlConfig, flags) {
|
|
54
|
+
const check = await ctx.spinner(' Checking domain verification...', flags.json, () => ctx.get(API_ENDPOINTS.DOMAIN_VERIFY, {
|
|
55
|
+
domain: yamlConfig.project?.domain || '',
|
|
56
|
+
}));
|
|
57
|
+
if (check.ok && check.data?.domainStatus === 'VERIFIED')
|
|
58
|
+
return true;
|
|
59
|
+
if (yamlConfig.project?.domain) {
|
|
60
|
+
if (!flags.json)
|
|
61
|
+
ctx.log(`\n ${INFO.DOMAIN_PENDING_VERIFICATION}\n`);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
if (!flags.json) {
|
|
65
|
+
ctx.log(`\n No sending domain set up yet.`);
|
|
66
|
+
ctx.log(` You need a verified domain before sending emails.`);
|
|
67
|
+
ctx.log(` This is a one-time setup. Takes about 5 minutes.\n`);
|
|
68
|
+
}
|
|
69
|
+
if (!flags.yes) {
|
|
70
|
+
const go = await confirm({
|
|
71
|
+
default: true,
|
|
72
|
+
message: 'Set up your sending domain now?',
|
|
73
|
+
});
|
|
74
|
+
if (!go) {
|
|
75
|
+
ctx.log(`\n ${INFO.SEQUENCES_NOT_DEPLOYED}\n Emails will not send until your domain is verified.\n ${INFO.DOMAIN_NOT_DEPLOYED_HINT}\n`);
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return runDomainSetup(ctx, yamlConfig, flags);
|
|
80
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type MailmodoYaml } from '../yaml-config.js';
|
|
2
|
+
import type { DeployCtx, DeployFlags } from './types.js';
|
|
3
|
+
export declare function getMissingTemplateIds(yamlConfig: MailmodoYaml): string[];
|
|
4
|
+
export declare function handleMissingTemplates(ctx: DeployCtx, yamlConfig: MailmodoYaml, missingIds: string[], flags: DeployFlags): Promise<void>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { select } from '@inquirer/prompts';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { API_ENDPOINTS, TEMPLATES_DIR } from '../constants.js';
|
|
6
|
+
import { MISSING_TEMPLATES } from '../messages.js';
|
|
7
|
+
import { saveTemplate } from '../yaml-config.js';
|
|
8
|
+
import { buildRegeneratePayload } from './payload.js';
|
|
9
|
+
export function getMissingTemplateIds(yamlConfig) {
|
|
10
|
+
return yamlConfig.emails
|
|
11
|
+
.filter((e) => !existsSync(join(process.cwd(), TEMPLATES_DIR, `${e.id}.html`)))
|
|
12
|
+
.map((e) => e.id);
|
|
13
|
+
}
|
|
14
|
+
async function regenerateMissingTemplates(ctx, yamlConfig, missingIds, flags) {
|
|
15
|
+
const response = await ctx.spinner(' Regenerating email templates...', 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 (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
|
+
ctx.log(`\n ${chalk.green('✓')} ${MISSING_TEMPLATES.REVIEW_HINT}\n`);
|
|
28
|
+
}
|
|
29
|
+
export async function handleMissingTemplates(ctx, yamlConfig, missingIds, flags) {
|
|
30
|
+
if (flags.json) {
|
|
31
|
+
ctx.log(JSON.stringify({
|
|
32
|
+
error: 'missing_templates',
|
|
33
|
+
message: MISSING_TEMPLATES.YES_ERROR,
|
|
34
|
+
missingTemplates: missingIds.map((id) => `${id}.html`),
|
|
35
|
+
}, null, 2));
|
|
36
|
+
ctx.exit(1);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (flags.yes)
|
|
40
|
+
ctx.error(MISSING_TEMPLATES.YES_ERROR);
|
|
41
|
+
ctx.log(`\n ${MISSING_TEMPLATES.HEADER}`);
|
|
42
|
+
for (const id of missingIds)
|
|
43
|
+
ctx.log(` ${chalk.red('✗')} mailmodo/${id}.html`);
|
|
44
|
+
ctx.log(`\n ${MISSING_TEMPLATES.REGENERATE_NOTE}\n`);
|
|
45
|
+
const action = await select({
|
|
46
|
+
choices: [
|
|
47
|
+
{ name: 'Re-generate via AI', value: 'regenerate' },
|
|
48
|
+
{ name: 'Abort (restore from version control)', value: 'abort' },
|
|
49
|
+
],
|
|
50
|
+
message: 'What would you like to do?',
|
|
51
|
+
});
|
|
52
|
+
if (action === 'abort') {
|
|
53
|
+
ctx.log(`\n ${MISSING_TEMPLATES.ABORT_HINT}\n`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
await regenerateMissingTemplates(ctx, yamlConfig, missingIds, flags);
|
|
57
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { MailmodoYaml } from '../yaml-config.js';
|
|
2
|
+
import type { DeployCtx, SdkSnippet, ValidateResponse } from './types.js';
|
|
3
|
+
export declare function logDiff(ctx: DeployCtx, diff: NonNullable<ValidateResponse['diff']>): void;
|
|
4
|
+
export declare function logPreDeploySummary(ctx: DeployCtx, yamlConfig: MailmodoYaml, validateResult: ValidateResponse, jsonOutput: boolean): void;
|
|
5
|
+
export declare function logDeploySuccessInstructions(ctx: DeployCtx, sdkSnippet: SdkSnippet): void;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { SDK_IMPORT_SNIPPET, SDK_INSTALL_COMMAND } from '../constants.js';
|
|
3
|
+
import { SEPARATOR } from '../messages.js';
|
|
4
|
+
export function logDiff(ctx, diff) {
|
|
5
|
+
if (!diff.hasChanges) {
|
|
6
|
+
ctx.log(` No changes from last deployment.`);
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
ctx.log(` Changes vs. last deployment:`);
|
|
10
|
+
for (const email of diff.added) {
|
|
11
|
+
ctx.log(` ${chalk.green('+')} ${email.id.padEnd(24)} ${email.trigger || ''}`);
|
|
12
|
+
}
|
|
13
|
+
for (const email of diff.removed) {
|
|
14
|
+
ctx.log(` ${chalk.red('-')} ${email.id.padEnd(24)} ${email.trigger || ''}`);
|
|
15
|
+
}
|
|
16
|
+
for (const email of diff.modified) {
|
|
17
|
+
ctx.log(` ${chalk.yellow('~')} ${email.id.padEnd(24)} ${email.changedFields?.join(', ') || ''}`);
|
|
18
|
+
}
|
|
19
|
+
if (diff.unchanged.length > 0)
|
|
20
|
+
ctx.log(` ${chalk.dim(`∙ ${diff.unchanged.length} unchanged`)}`);
|
|
21
|
+
}
|
|
22
|
+
export function logPreDeploySummary(ctx, yamlConfig, validateResult, jsonOutput) {
|
|
23
|
+
if (jsonOutput)
|
|
24
|
+
return;
|
|
25
|
+
ctx.log(`\n ${chalk.green('✓')} Domain: ${yamlConfig.project?.domain || 'verified'}\n`);
|
|
26
|
+
if (!validateResult.existingDeployment || !validateResult.diff) {
|
|
27
|
+
ctx.log(` Deploying:`);
|
|
28
|
+
for (const email of yamlConfig.emails) {
|
|
29
|
+
ctx.log(` ${chalk.green('+')} ${email.id.padEnd(24)} ${email.trigger}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
logDiff(ctx, validateResult.diff);
|
|
34
|
+
}
|
|
35
|
+
ctx.log('');
|
|
36
|
+
}
|
|
37
|
+
export function logDeploySuccessInstructions(ctx, sdkSnippet) {
|
|
38
|
+
ctx.log(` ${chalk.green('Deployed.')} Emails are live.\n`);
|
|
39
|
+
ctx.log(` ${SEPARATOR}`);
|
|
40
|
+
ctx.log(` ${chalk.bold('ADD THIS TO YOUR APP (one-time only):')}`);
|
|
41
|
+
ctx.log(` ${SEPARATOR}\n`);
|
|
42
|
+
ctx.log(` ${chalk.cyan(sdkSnippet.install ?? SDK_INSTALL_COMMAND)}\n`);
|
|
43
|
+
ctx.log(` ${chalk.dim(SDK_IMPORT_SNIPPET)}\n`);
|
|
44
|
+
if (sdkSnippet.examples) {
|
|
45
|
+
ctx.log(` ${chalk.dim('// Example usage:')}`);
|
|
46
|
+
ctx.log(` ${chalk.dim(sdkSnippet.examples.track)}`);
|
|
47
|
+
ctx.log(` ${chalk.dim(sdkSnippet.examples.identify)}\n`);
|
|
48
|
+
}
|
|
49
|
+
const trackCalls = [...new Set(sdkSnippet.trackCalls ?? [])];
|
|
50
|
+
for (const call of trackCalls)
|
|
51
|
+
ctx.log(` ${chalk.dim(call)}`);
|
|
52
|
+
if (trackCalls.length > 0)
|
|
53
|
+
ctx.log('');
|
|
54
|
+
const identifyCalls = [...new Set(sdkSnippet.identifyCalls ?? [])];
|
|
55
|
+
for (const call of identifyCalls)
|
|
56
|
+
ctx.log(` ${chalk.dim(call)}`);
|
|
57
|
+
if (identifyCalls.length > 0)
|
|
58
|
+
ctx.log('');
|
|
59
|
+
ctx.log(` Full SDK docs: ${chalk.cyan('mailmodo.com/docs/sdk')}\n`);
|
|
60
|
+
ctx.log(` ${SEPARATOR}\n`);
|
|
61
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type EmailConfig, type MailmodoYaml, type ProjectConfig } from '../yaml-config.js';
|
|
2
|
+
import type { DeployCtx } from './types.js';
|
|
3
|
+
export declare function mapEmailToPayload(email: EmailConfig): {
|
|
4
|
+
condition: string | null;
|
|
5
|
+
ctaText: string;
|
|
6
|
+
delay: string | number;
|
|
7
|
+
goal: string;
|
|
8
|
+
id: string;
|
|
9
|
+
isReminder: boolean;
|
|
10
|
+
previewText: string;
|
|
11
|
+
priority: string;
|
|
12
|
+
subject: string;
|
|
13
|
+
trigger: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function buildProjectPayload(project: ProjectConfig, monthlyCap: number | undefined): {
|
|
16
|
+
webhookUrl?: string | undefined;
|
|
17
|
+
product: {
|
|
18
|
+
businessType: string;
|
|
19
|
+
description: string;
|
|
20
|
+
pricingModel: string;
|
|
21
|
+
productName: string;
|
|
22
|
+
saasModel: string;
|
|
23
|
+
targetUser: string;
|
|
24
|
+
url: string;
|
|
25
|
+
};
|
|
26
|
+
senderDetails: {
|
|
27
|
+
address: string;
|
|
28
|
+
domain: string;
|
|
29
|
+
fromEmail: string;
|
|
30
|
+
fromName: string;
|
|
31
|
+
replyTo: string;
|
|
32
|
+
};
|
|
33
|
+
monthlyCap?: number | undefined;
|
|
34
|
+
brand: {
|
|
35
|
+
colors: string[];
|
|
36
|
+
logoUrl: string;
|
|
37
|
+
};
|
|
38
|
+
emailStyle: "branded" | "plain";
|
|
39
|
+
};
|
|
40
|
+
export declare function buildRegeneratePayload(yamlConfig: MailmodoYaml, missingIds: string[]): Record<string, unknown>;
|
|
41
|
+
export declare function buildDeployPayload(ctx: DeployCtx, yamlConfig: MailmodoYaml): Promise<object>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { loadTemplate, } from '../yaml-config.js';
|
|
2
|
+
import { DEFAULT_BRAND_COLOR } from '../constants.js';
|
|
3
|
+
export function mapEmailToPayload(email) {
|
|
4
|
+
return {
|
|
5
|
+
condition: email.condition || null,
|
|
6
|
+
ctaText: email.ctaText || '',
|
|
7
|
+
delay: email.delay,
|
|
8
|
+
goal: email.goal || '',
|
|
9
|
+
id: email.id,
|
|
10
|
+
isReminder: false,
|
|
11
|
+
previewText: email.previewText || '',
|
|
12
|
+
priority: 'medium',
|
|
13
|
+
subject: email.subject,
|
|
14
|
+
trigger: email.trigger,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function buildBrandSection(project) {
|
|
18
|
+
return {
|
|
19
|
+
colors: [project?.brandColor || DEFAULT_BRAND_COLOR],
|
|
20
|
+
logoUrl: project?.logoUrl || '',
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function buildProductSection(project) {
|
|
24
|
+
return {
|
|
25
|
+
businessType: project?.type || '',
|
|
26
|
+
description: project?.description || '',
|
|
27
|
+
pricingModel: project?.pricingModel || '',
|
|
28
|
+
productName: project?.name || '',
|
|
29
|
+
saasModel: project?.saasModel || '',
|
|
30
|
+
targetUser: project?.targetUser || '',
|
|
31
|
+
url: project?.url || '',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function buildSenderSection(project) {
|
|
35
|
+
return {
|
|
36
|
+
address: project?.address || '',
|
|
37
|
+
domain: project?.domain || '',
|
|
38
|
+
fromEmail: project?.fromEmail || '',
|
|
39
|
+
fromName: project?.fromName || '',
|
|
40
|
+
replyTo: project?.replyTo || project?.fromEmail || '',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function buildProjectPayload(project, monthlyCap) {
|
|
44
|
+
return {
|
|
45
|
+
brand: buildBrandSection(project),
|
|
46
|
+
emailStyle: project?.emailStyle || 'branded',
|
|
47
|
+
...(monthlyCap === undefined ? {} : { monthlyCap }),
|
|
48
|
+
product: buildProductSection(project),
|
|
49
|
+
senderDetails: buildSenderSection(project),
|
|
50
|
+
...(project?.webhookUrl ? { webhookUrl: project.webhookUrl } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function buildRegeneratePayload(yamlConfig, missingIds) {
|
|
54
|
+
const { emails, project } = yamlConfig;
|
|
55
|
+
const idSet = new Set(missingIds);
|
|
56
|
+
const targets = emails.filter((e) => idSet.has(e.id));
|
|
57
|
+
return {
|
|
58
|
+
brand: {
|
|
59
|
+
color: project.brandColor || DEFAULT_BRAND_COLOR,
|
|
60
|
+
logoUrl: project.logoUrl || '',
|
|
61
|
+
},
|
|
62
|
+
businessType: project.type || '',
|
|
63
|
+
description: project.description || '',
|
|
64
|
+
events: [...new Set(targets.map((e) => e.trigger))],
|
|
65
|
+
pricingModel: project.pricingModel || '',
|
|
66
|
+
productName: project.name || '',
|
|
67
|
+
recommendedEmails: targets.map((e) => ({
|
|
68
|
+
condition: e.condition || null,
|
|
69
|
+
delay: String(e.delay ?? '0'),
|
|
70
|
+
goal: e.goal || '',
|
|
71
|
+
id: e.id,
|
|
72
|
+
isReminder: false,
|
|
73
|
+
priority: 'medium',
|
|
74
|
+
trigger: e.trigger,
|
|
75
|
+
})),
|
|
76
|
+
saasModel: project.saasModel || '',
|
|
77
|
+
targetUser: project.targetUser || '',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export async function buildDeployPayload(ctx, yamlConfig) {
|
|
81
|
+
const [emailsWithHtml, monthlyCap] = await Promise.all([
|
|
82
|
+
Promise.all(yamlConfig.emails.map(async (email) => {
|
|
83
|
+
const html = (await loadTemplate(`${email.id}.html`)) || '';
|
|
84
|
+
const plainHtml = (await loadTemplate(`${email.id}_plain.html`)) || html;
|
|
85
|
+
return { ...mapEmailToPayload(email), html, plainHtml };
|
|
86
|
+
})),
|
|
87
|
+
yamlConfig.project.monthlyCap === undefined
|
|
88
|
+
? ctx.getBillingCap()
|
|
89
|
+
: Promise.resolve(yamlConfig.project.monthlyCap),
|
|
90
|
+
]);
|
|
91
|
+
return {
|
|
92
|
+
...buildProjectPayload(yamlConfig.project, monthlyCap),
|
|
93
|
+
emails: emailsWithHtml,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { DeployCtx, DeployFlags } from './types.js';
|
|
2
|
+
export declare function pauseSequence(ctx: DeployCtx, sequenceId: string, flags: DeployFlags): Promise<void>;
|
|
3
|
+
export declare function resumeSequence(ctx: DeployCtx, sequenceId: string, flags: DeployFlags): Promise<void>;
|