@emailens/cli 0.3.2 → 0.3.4

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/README.md CHANGED
@@ -1,6 +1,11 @@
1
1
  # @emailens/cli
2
2
 
3
- CLI tool for email compatibility analysis — preview how HTML emails render across 12 email clients (Gmail, Outlook, Apple Mail, Yahoo, Samsung, Thunderbird, HEY, Superhuman).
3
+ [![npm](https://img.shields.io/npm/v/@emailens/cli)](https://www.npmjs.com/package/@emailens/cli)
4
+ [![MCP](https://img.shields.io/badge/MCP-Server-blue)](https://github.com/emailens/mcp)
5
+
6
+ CLI tool for email compatibility analysis — preview how HTML emails render across 15 email clients (Gmail, Outlook, Apple Mail, Yahoo, Samsung, Thunderbird, HEY, Superhuman).
7
+
8
+ > **Prefer AI?** Use the [MCP server](https://github.com/emailens/mcp) — same engine, works with Claude, Cursor, and any MCP client.
4
9
 
5
10
  ## Install
6
11
 
@@ -74,9 +79,75 @@ cat email.html | emailens fix - --format jsx # Pipe from stdin
74
79
  | `--estimate` | | Only show token estimate without calling the AI |
75
80
  | `--max-tokens` | | Maximum input tokens for the prompt (default: 16000) |
76
81
 
82
+ ### `emailens lint <file|glob>`
83
+
84
+ CI/CD-friendly linting with structured exit codes. Flattens all audit checks (compatibility, content hygiene, links, accessibility, images, inbox preview, size, template variables) into a unified issue list.
85
+
86
+ ```bash
87
+ emailens lint email.html
88
+ emailens lint src/*.html
89
+ emailens lint email.html --json
90
+ emailens lint email.html --fail-on-warning
91
+ emailens lint email.html --skip spam,links
92
+ emailens lint email.html --max-warnings 5
93
+ ```
94
+
95
+ | Flag | Alias | Description |
96
+ |------|-------|-------------|
97
+ | `--format` | `-f` | Input format: `html`, `jsx`, `mjml`, `maizzle` |
98
+ | `--json` | | Output as JSON |
99
+ | `--fail-on-warning` | | Exit 2 if warnings found |
100
+ | `--skip` | | Comma-separated checks to skip: `spam,links,accessibility,images,compatibility,inboxPreview,size,templateVariables` |
101
+ | `--max-warnings` | | Fail if more than n warnings |
102
+
103
+ **Exit codes:**
104
+ - `0` — clean
105
+ - `1` — errors found
106
+ - `2` — warnings only (with `--fail-on-warning` or `--max-warnings` exceeded)
107
+
108
+ **Output format:**
109
+
110
+ ```
111
+ src/emails/welcome.html
112
+ error outlook-windows border-radius Not supported in Outlook Windows
113
+ warn spam caps-ratio 20%+ of words are ALL CAPS
114
+
115
+ src/emails/newsletter.html
116
+ pass No issues found
117
+
118
+ 2 files | 1 error | 1 warning
119
+ ```
120
+
121
+ #### CI / GitHub Actions
122
+
123
+ Drop this into `.github/workflows/email-lint.yml` to fail PRs that introduce broken email CSS, spam triggers, or accessibility regressions:
124
+
125
+ ```yaml
126
+ name: Email lint
127
+
128
+ on:
129
+ pull_request:
130
+ paths:
131
+ - 'emails/**'
132
+ - 'src/emails/**'
133
+
134
+ jobs:
135
+ lint:
136
+ runs-on: ubuntu-latest
137
+ steps:
138
+ - uses: actions/checkout@v4
139
+ - uses: actions/setup-node@v4
140
+ with:
141
+ node-version: 22
142
+ - name: Lint emails
143
+ run: npx -y @emailens/cli lint 'emails/**/*.{html,tsx,mjml}' --fail-on-warning
144
+ ```
145
+
146
+ For React Email / MJML / Maizzle source files, the CLI auto-detects the format from the extension. Want full preview reports (with screenshots and shareable links) on every PR? Use the [Emailens GitHub Action](https://github.com/marketplace/actions/emailens-email-preview-check) instead — it wraps the same engine.
147
+
77
148
  ### `emailens clients`
78
149
 
79
- List all 12 supported email clients.
150
+ List all 15 supported email clients.
80
151
 
81
152
  ```bash
82
153
  emailens clients
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
3
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
4
+ }) : x)(function(x) {
5
+ if (typeof require !== "undefined") return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
8
+
9
+ export {
10
+ __require
11
+ };
12
+ //# sourceMappingURL=chunk-7OCVIDC7.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/dist/index.js CHANGED
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ __require
4
+ } from "./chunk-7OCVIDC7.js";
2
5
 
3
6
  // src/index.ts
4
- import { defineCommand as defineCommand7, runMain } from "citty";
7
+ import { defineCommand as defineCommand8, runMain } from "citty";
5
8
 
6
9
  // src/commands/analyze.ts
7
10
  import { defineCommand } from "citty";
@@ -24,7 +27,7 @@ async function readInput(pathOrStdin) {
24
27
  return readFileSync("/dev/stdin", "utf-8");
25
28
  } catch {
26
29
  }
27
- return new Promise((resolve2, reject) => {
30
+ return new Promise((resolve3, reject) => {
28
31
  const chunks = [];
29
32
  process.stdin.resume();
30
33
  process.stdin.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
@@ -33,7 +36,7 @@ async function readInput(pathOrStdin) {
33
36
  if (!data.trim()) {
34
37
  reject(new Error("No input received from stdin. Pipe HTML or provide a file path."));
35
38
  } else {
36
- resolve2(data);
39
+ resolve3(data);
37
40
  }
38
41
  });
39
42
  process.stdin.on("error", () => {
@@ -394,7 +397,7 @@ var preview_default = defineCommand2({
394
397
  const outDir = args.out ?? "./emailens-screenshots";
395
398
  spinner?.start("Capturing screenshots...");
396
399
  try {
397
- const { captureScreenshots } = await import("./screenshots-PMZQU5V7.js");
400
+ const { captureScreenshots } = await import("./screenshots-UOLRXFAJ.js");
398
401
  const results = await captureScreenshots(
399
402
  transforms,
400
403
  outDir,
@@ -566,6 +569,11 @@ function escapeHtml(str) {
566
569
  }
567
570
 
568
571
  // src/commands/export.ts
572
+ var toPlainText = null;
573
+ try {
574
+ ({ toPlainText } = __require("@emailens/engine"));
575
+ } catch {
576
+ }
569
577
  var export_default = defineCommand4({
570
578
  meta: {
571
579
  name: "export",
@@ -605,6 +613,10 @@ var export_default = defineCommand4({
605
613
  type: "boolean",
606
614
  description: "Export as JSON instead of HTML"
607
615
  },
616
+ "plain-text": {
617
+ type: "boolean",
618
+ description: "Also export a plain text version (.txt) for multipart emails"
619
+ },
608
620
  quiet: {
609
621
  type: "boolean",
610
622
  alias: "q",
@@ -650,7 +662,7 @@ var export_default = defineCommand4({
650
662
  if (args.screenshots) {
651
663
  spinner?.start("Capturing screenshots...");
652
664
  try {
653
- const { captureScreenshots } = await import("./screenshots-PMZQU5V7.js");
665
+ const { captureScreenshots } = await import("./screenshots-UOLRXFAJ.js");
654
666
  const screenshotDir = join(outDir, "screenshots");
655
667
  const results = await captureScreenshots(
656
668
  transforms,
@@ -684,6 +696,15 @@ var export_default = defineCommand4({
684
696
  writeFileSync(htmlPath, htmlReport);
685
697
  spinner?.succeed(`HTML report saved to ${htmlPath}`);
686
698
  }
699
+ if (args["plain-text"] && toPlainText) {
700
+ spinner?.start("Generating plain text version...");
701
+ const plainText = toPlainText(html);
702
+ const txtPath = join(outDir, "email.txt");
703
+ writeFileSync(txtPath, plainText);
704
+ spinner?.succeed(`Plain text saved to ${txtPath}`);
705
+ } else if (args["plain-text"] && !toPlainText) {
706
+ spinner?.warn("Plain text export requires @emailens/engine >=0.8.6");
707
+ }
687
708
  if (!args.quiet) {
688
709
  console.log();
689
710
  console.log(` Output: ${outDir}`);
@@ -840,7 +861,7 @@ var fix_default = defineCommand5({
840
861
  spinner?.start("Generating AI fix...");
841
862
  let Anthropic;
842
863
  try {
843
- Anthropic = (await import("./sdk-HQZAL655.js")).default;
864
+ Anthropic = (await import("./sdk-IYJIYBYM.js")).default;
844
865
  } catch {
845
866
  spinner?.fail("@anthropic-ai/sdk is required for the fix command");
846
867
  console.error(pc3.dim("\n Install it with: npm install @anthropic-ai/sdk\n"));
@@ -990,7 +1011,7 @@ function printQualitySummary(report, skip, options) {
990
1011
  if (!skip.includes("spam")) {
991
1012
  const { score, level, issues } = report.spam;
992
1013
  const color = score >= 90 ? pc4.green : score >= 70 ? pc4.yellow : pc4.red;
993
- table.push(["Spam Score", `${color(String(score))}/100 (${level}) \u2014 ${issues.length} issue${issues.length === 1 ? "" : "s"}`]);
1014
+ table.push(["Content Hygiene", `${color(String(score))}/100 (${level}) \u2014 ${issues.length} issue${issues.length === 1 ? "" : "s"}`]);
994
1015
  }
995
1016
  if (!skip.includes("links")) {
996
1017
  const { totalLinks, issues } = report.links;
@@ -1011,11 +1032,275 @@ function printQualitySummary(report, skip, options) {
1011
1032
  console.log();
1012
1033
  }
1013
1034
 
1035
+ // src/commands/lint.ts
1036
+ import { defineCommand as defineCommand7 } from "citty";
1037
+ import pc5 from "picocolors";
1038
+ import { compile as compile6 } from "@emailens/engine/compile";
1039
+ import {
1040
+ auditEmail as auditEmail2,
1041
+ CompileError as CompileError6
1042
+ } from "@emailens/engine";
1043
+ import { readdir } from "fs/promises";
1044
+ import { resolve as resolve2, relative } from "path";
1045
+ var VALID_SKIPS2 = /* @__PURE__ */ new Set([
1046
+ "spam",
1047
+ "links",
1048
+ "accessibility",
1049
+ "images",
1050
+ "compatibility",
1051
+ "inboxPreview",
1052
+ "size",
1053
+ "templateVariables"
1054
+ ]);
1055
+ var lint_default = defineCommand7({
1056
+ meta: {
1057
+ name: "lint",
1058
+ description: "Lint HTML emails for compatibility, spam, accessibility, and more. CI/CD-friendly with structured exit codes."
1059
+ },
1060
+ args: {
1061
+ input: {
1062
+ type: "positional",
1063
+ description: "HTML file path or glob pattern",
1064
+ required: true
1065
+ },
1066
+ format: {
1067
+ type: "string",
1068
+ alias: "f",
1069
+ description: "Input format: html, jsx, mjml, maizzle"
1070
+ },
1071
+ json: {
1072
+ type: "boolean",
1073
+ description: "Output as JSON"
1074
+ },
1075
+ failOnWarning: {
1076
+ type: "boolean",
1077
+ description: "Exit 2 if warnings found"
1078
+ },
1079
+ skip: {
1080
+ type: "string",
1081
+ description: "Comma-separated checks to skip: spam,links,accessibility,images,compatibility,inboxPreview,size,templateVariables"
1082
+ },
1083
+ maxWarnings: {
1084
+ type: "string",
1085
+ description: "Fail if more than n warnings"
1086
+ }
1087
+ },
1088
+ async run({ args }) {
1089
+ try {
1090
+ const skip = [];
1091
+ if (args.skip) {
1092
+ for (const s of args.skip.split(",").map((s2) => s2.trim()).filter(Boolean)) {
1093
+ if (!VALID_SKIPS2.has(s)) {
1094
+ throw new Error(`Unknown skip value "${s}". Valid: ${Array.from(VALID_SKIPS2).join(", ")}`);
1095
+ }
1096
+ skip.push(s);
1097
+ }
1098
+ }
1099
+ const maxWarningsRaw = args.maxWarnings !== void 0 ? parseInt(args.maxWarnings, 10) : void 0;
1100
+ if (maxWarningsRaw !== void 0 && isNaN(maxWarningsRaw)) {
1101
+ throw new Error(`--maxWarnings must be a number, got: ${args.maxWarnings}`);
1102
+ }
1103
+ const maxWarnings = maxWarningsRaw ?? Infinity;
1104
+ const files = await resolveGlob(args.input);
1105
+ if (files.length === 0) {
1106
+ if (args.json) {
1107
+ console.log(JSON.stringify({ files: [], totalErrors: 0, totalWarnings: 0 }));
1108
+ } else {
1109
+ console.error(pc5.red(`No files found matching: ${args.input}`));
1110
+ }
1111
+ process.exit(1);
1112
+ }
1113
+ const results = [];
1114
+ let totalErrors = 0;
1115
+ let totalWarnings = 0;
1116
+ for (const file of files) {
1117
+ const format = resolveFormat(args.format, file);
1118
+ const source = await readInput(file);
1119
+ const html = await compile6(source, format, file);
1120
+ const framework = toFramework(format);
1121
+ const report = auditEmail2(html, {
1122
+ framework,
1123
+ skip
1124
+ });
1125
+ const issues = flattenToLintIssues(report, skip);
1126
+ const errors = issues.filter((i) => i.severity === "error").length;
1127
+ const warnings = issues.filter((i) => i.severity === "warning").length;
1128
+ results.push({ file: relative(process.cwd(), file), issues, errors, warnings });
1129
+ totalErrors += errors;
1130
+ totalWarnings += warnings;
1131
+ }
1132
+ if (args.json) {
1133
+ console.log(JSON.stringify({ files: results, totalErrors, totalWarnings }, null, 2));
1134
+ } else {
1135
+ for (const result of results) {
1136
+ console.log(pc5.underline(result.file));
1137
+ if (result.issues.length === 0) {
1138
+ console.log(` ${pc5.green("pass")} No issues found`);
1139
+ } else {
1140
+ for (const issue of result.issues) {
1141
+ const sev = issue.severity === "error" ? pc5.red("error") : issue.severity === "warning" ? pc5.yellow("warn ") : pc5.blue("info ");
1142
+ console.log(` ${sev} ${pc5.dim(issue.category.padEnd(18))} ${pc5.bold(issue.rule.padEnd(22))} ${issue.message}`);
1143
+ }
1144
+ }
1145
+ console.log();
1146
+ }
1147
+ const summary = [
1148
+ `${files.length} file${files.length === 1 ? "" : "s"}`,
1149
+ totalErrors > 0 ? pc5.red(`${totalErrors} error${totalErrors === 1 ? "" : "s"}`) : null,
1150
+ totalWarnings > 0 ? pc5.yellow(`${totalWarnings} warning${totalWarnings === 1 ? "" : "s"}`) : null,
1151
+ totalErrors === 0 && totalWarnings === 0 ? pc5.green("all clean") : null
1152
+ ].filter(Boolean).join(" | ");
1153
+ console.log(summary);
1154
+ }
1155
+ if (totalErrors > 0) {
1156
+ process.exit(1);
1157
+ }
1158
+ if (args.failOnWarning && totalWarnings > 0) {
1159
+ if (!args.json) {
1160
+ console.error(pc5.yellow(`
1161
+ Failed: ${totalWarnings} warning${totalWarnings === 1 ? "" : "s"} found (--failOnWarning)`));
1162
+ }
1163
+ process.exit(2);
1164
+ }
1165
+ if (Number.isFinite(maxWarnings) && totalWarnings > maxWarnings) {
1166
+ if (!args.json) {
1167
+ console.error(pc5.red(`
1168
+ Exceeded max warnings: ${totalWarnings} > ${maxWarnings}`));
1169
+ }
1170
+ process.exit(2);
1171
+ }
1172
+ } catch (err) {
1173
+ if (err instanceof CompileError6) {
1174
+ console.error(pc5.red(`Compilation failed (${err.phase}): ${err.message}`));
1175
+ } else {
1176
+ console.error(pc5.red(err.message));
1177
+ }
1178
+ process.exit(1);
1179
+ }
1180
+ }
1181
+ });
1182
+ async function resolveGlob(pattern) {
1183
+ if (!pattern.includes("*")) {
1184
+ return [resolve2(pattern)];
1185
+ }
1186
+ if (pattern.includes("**")) {
1187
+ throw new Error("Recursive glob (**) is not supported. Use a single-level wildcard (e.g., src/*.html) or list files explicitly.");
1188
+ }
1189
+ const lastSep = Math.max(pattern.lastIndexOf("/"), pattern.lastIndexOf("\\"));
1190
+ const dir = lastSep >= 0 ? pattern.slice(0, lastSep) : ".";
1191
+ const glob = lastSep >= 0 ? pattern.slice(lastSep + 1) : pattern;
1192
+ const regex = new RegExp("^" + glob.replace(/\*/g, ".*") + "$");
1193
+ const entries = await readdir(resolve2(dir), { withFileTypes: true });
1194
+ return entries.filter((e) => e.isFile() && regex.test(e.name)).map((e) => resolve2(dir, e.name)).sort();
1195
+ }
1196
+ function flattenToLintIssues(report, skip) {
1197
+ const issues = [];
1198
+ if (!skip.includes("compatibility")) {
1199
+ const seenProps = /* @__PURE__ */ new Map();
1200
+ for (const w of report.compatibility.warnings) {
1201
+ const key = `${w.property}\0${w.severity}`;
1202
+ const existing = seenProps.get(key);
1203
+ if (existing) {
1204
+ existing.clients.push(w.client);
1205
+ } else {
1206
+ seenProps.set(key, {
1207
+ severity: w.severity,
1208
+ clients: [w.client],
1209
+ message: w.message
1210
+ });
1211
+ }
1212
+ }
1213
+ for (const [key, val] of seenProps) {
1214
+ const property = key.split("\0")[0];
1215
+ issues.push({
1216
+ severity: val.severity,
1217
+ category: val.clients.slice(0, 3).join(",") + (val.clients.length > 3 ? `+${val.clients.length - 3}` : ""),
1218
+ rule: property,
1219
+ message: val.message
1220
+ });
1221
+ }
1222
+ }
1223
+ if (!skip.includes("spam")) {
1224
+ for (const issue of report.spam.issues) {
1225
+ issues.push({
1226
+ severity: issue.severity,
1227
+ category: "spam",
1228
+ rule: issue.rule,
1229
+ message: issue.message,
1230
+ detail: issue.detail
1231
+ });
1232
+ }
1233
+ }
1234
+ if (!skip.includes("links")) {
1235
+ for (const issue of report.links.issues) {
1236
+ issues.push({
1237
+ severity: issue.severity,
1238
+ category: "links",
1239
+ rule: issue.rule,
1240
+ message: issue.message
1241
+ });
1242
+ }
1243
+ }
1244
+ if (!skip.includes("accessibility")) {
1245
+ for (const issue of report.accessibility.issues) {
1246
+ issues.push({
1247
+ severity: issue.severity,
1248
+ category: "accessibility",
1249
+ rule: issue.rule,
1250
+ message: issue.message
1251
+ });
1252
+ }
1253
+ }
1254
+ if (!skip.includes("images")) {
1255
+ for (const issue of report.images.issues) {
1256
+ issues.push({
1257
+ severity: issue.severity,
1258
+ category: "images",
1259
+ rule: issue.rule,
1260
+ message: issue.message
1261
+ });
1262
+ }
1263
+ }
1264
+ if (!skip.includes("inboxPreview")) {
1265
+ for (const issue of report.inboxPreview.issues) {
1266
+ issues.push({
1267
+ severity: issue.severity,
1268
+ category: "inboxPreview",
1269
+ rule: issue.rule,
1270
+ message: issue.message
1271
+ });
1272
+ }
1273
+ }
1274
+ if (!skip.includes("size")) {
1275
+ for (const issue of report.size.issues) {
1276
+ issues.push({
1277
+ severity: issue.severity,
1278
+ category: "size",
1279
+ rule: issue.rule,
1280
+ message: issue.message
1281
+ });
1282
+ }
1283
+ }
1284
+ if (!skip.includes("templateVariables")) {
1285
+ for (const issue of report.templateVariables.issues) {
1286
+ issues.push({
1287
+ severity: issue.severity,
1288
+ category: "templateVars",
1289
+ rule: issue.rule,
1290
+ message: issue.message
1291
+ });
1292
+ }
1293
+ }
1294
+ const order = { error: 0, warning: 1, info: 2 };
1295
+ issues.sort((a, b) => order[a.severity] - order[b.severity]);
1296
+ return issues;
1297
+ }
1298
+
1014
1299
  // src/index.ts
1015
- var main = defineCommand7({
1300
+ var main = defineCommand8({
1016
1301
  meta: {
1017
1302
  name: "emailens",
1018
- version: "0.3.0",
1303
+ version: "0.4.0",
1019
1304
  description: "Email compatibility analysis CLI \u2014 preview how HTML emails render across 12 email clients."
1020
1305
  },
1021
1306
  subCommands: {
@@ -1024,7 +1309,8 @@ var main = defineCommand7({
1024
1309
  clients: clients_default,
1025
1310
  export: export_default,
1026
1311
  fix: fix_default,
1027
- audit: audit_default
1312
+ audit: audit_default,
1313
+ lint: lint_default
1028
1314
  }
1029
1315
  });
1030
1316
  runMain(main);