@emailens/engine 0.11.1 → 0.11.2

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
@@ -10,7 +10,7 @@
10
10
  [![CI](https://github.com/emailens/engine/actions/workflows/ci.yml/badge.svg)](https://github.com/emailens/engine/actions/workflows/ci.yml)
11
11
  [![npm](https://img.shields.io/npm/v/@emailens/engine)](https://www.npmjs.com/package/@emailens/engine)
12
12
  [![license](https://img.shields.io/npm/l/@emailens/engine)](./LICENSE)
13
- [![tests](https://img.shields.io/badge/tests-910%20passing-brightgreen)]()
13
+ [![tests](https://img.shields.io/badge/tests-1036%20passing-brightgreen)]()
14
14
  [![node](https://img.shields.io/node/v/@emailens/engine)](https://nodejs.org/)
15
15
  [![MCP](https://img.shields.io/badge/MCP-Server-blue)](https://github.com/emailens/mcp)
16
16
  [![GitHub stars](https://img.shields.io/github/stars/emailens/engine?style=flat)](https://github.com/emailens/engine/stargazers)
@@ -106,11 +106,14 @@ const { code } = await generateAiFix({
106
106
 
107
107
  ## What It Catches
108
108
 
109
- 10 analysis engines, one `auditEmail()` call.
109
+ 14 analysis engines, one `auditEmail()` call.
110
110
 
111
111
  - **CSS compatibility**: 255 CSS and HTML features tested across 21 email clients, with fix snippets and AI-powered auto-fix
112
+ - **Outlook VML**: structural faults in the Outlook-only markup inside `<!--[if mso]>` conditional comments — the one part of an email a DOM analyzer structurally cannot see, since to every HTML parser it is a comment node and to a screenshot it does not exist
112
113
  - **Content overflow**: fixed widths wider than the email frame and unbreakable strings that force horizontal scrolling
113
114
  - **Visual bugs**: gradients/background images with no color fallback (invisible content in Outlook) and fonts with no web-safe fallback, each with a concrete fix
115
+ - **Design consistency**: colours that differ as values but not to a reader (OKLab distance), and runaway cardinality in type sizes, families and corner radii
116
+ - **Dark and mobile contrast**: the two renders nobody checks — what a client's forced inversion does, what the email's own `prefers-color-scheme` block does, and what changes below the mobile breakpoint
114
117
  - **Spam scoring**: 45+ signals modeled after SpamAssassin, CAN-SPAM, and GDPR
115
118
  - **Accessibility**: WCAG contrast ratios, alt text, semantic structure, heading hierarchy
116
119
  - **Link validation**: broken hrefs, insecure HTTP, `javascript:` protocols, deceptive URLs
@@ -207,7 +210,7 @@ Full API reference: **[docs/API.md](./docs/API.md)**
207
210
 
208
211
  Covers:
209
212
  - `auditEmail` and `createSession`: core analysis
210
- - Standalone analyzers (CSS, spam, links, accessibility, images, inbox preview, size, templates)
213
+ - Standalone analyzers (CSS, VML, spam, links, accessibility, images, inbox preview, size, templates)
211
214
  - DNS deliverability and SpamAssassin integration
212
215
  - Client transforms and dark mode simulation
213
216
  - Compile module (JSX, MJML, Maizzle)
@@ -220,7 +223,7 @@ Covers:
220
223
 
221
224
  See [ROADMAP.md](./ROADMAP.md) for the full picture (shipped items + items under consideration with rationale).
222
225
 
223
- **Shipped:** automated caniemail.com data sync · GitHub Actions integration via `@emailens/cli` and the [Marketplace Action](https://github.com/marketplace/actions/emailens-email-preview-check) · AI-powered fix generation · compile module for JSX/MJML/Maizzle.
226
+ **Shipped:** Outlook VML structural validation (`checkVml`, verified against the Word engine) · automated caniemail.com data sync · GitHub Actions integration via `@emailens/cli` and the [Marketplace Action](https://github.com/marketplace/actions/emailens-email-preview-check) · AI-powered fix generation · compile module for JSX/MJML/Maizzle.
224
227
 
225
228
  **Considering:** Outlook VML auto-generation · plugin system for custom analyzers · MJML/Maizzle source-level linting · ESLint plugin · spam corpus tuning · dark-mode accuracy tests.
226
229
 
package/dist/index.cjs CHANGED
@@ -79,6 +79,7 @@ __export(index_exports, {
79
79
  analyzeEmail: () => analyzeEmail,
80
80
  analyzeImages: () => analyzeImages,
81
81
  analyzeSpam: () => analyzeSpam,
82
+ arcsizeToRadius: () => arcsizeToRadius,
82
83
  auditEmail: () => auditEmail,
83
84
  caveatApplies: () => caveatApplies,
84
85
  checkAccessibility: () => checkAccessibility,
@@ -110,6 +111,8 @@ __export(index_exports, {
110
111
  heuristicTokenCount: () => heuristicTokenCount,
111
112
  parseColor: () => parseColor,
112
113
  relativeLuminance: () => relativeLuminance,
114
+ renderOutlookBranch: () => renderOutlookBranch,
115
+ resolveMsoBranch: () => resolveMsoBranch,
113
116
  rgbToOklab: () => rgbToOklab,
114
117
  simulateDarkMode: () => simulateDarkMode,
115
118
  structuralWarnings: () => structuralWarnings,
@@ -117,6 +120,7 @@ __export(index_exports, {
117
120
  transformForAllClients: () => transformForAllClients,
118
121
  transformForClient: () => transformForClient,
119
122
  validateLinks: () => validateLinks,
123
+ vmlToCss: () => vmlToCss,
120
124
  warningsForClient: () => warningsForClient,
121
125
  wcagGrade: () => wcagGrade
122
126
  });
@@ -9855,6 +9859,114 @@ function downlevelCSS(html) {
9855
9859
  return $.html();
9856
9860
  }
9857
9861
 
9862
+ // src/vml-render.ts
9863
+ function px(value) {
9864
+ if (!value) return void 0;
9865
+ const m = value.trim().match(/^(-?[\d.]+)(?:px)?$/i);
9866
+ return m ? parseFloat(m[1]) : void 0;
9867
+ }
9868
+ function styleProps(style) {
9869
+ const out = {};
9870
+ for (const decl of style.split(";")) {
9871
+ const i = decl.indexOf(":");
9872
+ if (i > 0) out[decl.slice(0, i).trim().toLowerCase()] = decl.slice(i + 1).trim();
9873
+ }
9874
+ return out;
9875
+ }
9876
+ function attrs(raw) {
9877
+ var _a, _b;
9878
+ const out = {};
9879
+ for (const m of raw.matchAll(/([\w:-]+)\s*=\s*"([^"]*)"|([\w:-]+)\s*=\s*'([^']*)'/g)) {
9880
+ out[((_a = m[1]) != null ? _a : m[3]).toLowerCase()] = (_b = m[2]) != null ? _b : m[4];
9881
+ }
9882
+ return out;
9883
+ }
9884
+ function arcsizeToRadius(arcsize, width, height) {
9885
+ const raw = arcsize.trim();
9886
+ const pct = raw.endsWith("%") ? parseFloat(raw) : parseFloat(raw) * 100;
9887
+ if (!Number.isFinite(pct)) return 0;
9888
+ return Math.min(100, Math.max(0, pct)) / 100 * (Math.min(width, height) / 2);
9889
+ }
9890
+ function resolveMsoBranch(html) {
9891
+ return html.replace(/<!--\[if\s*!\s*(?:mso|vml)[^\]]*\]><!-->([\s\S]*?)<!--<!\[endif\]-->/gi, "").replace(/<!--\[if[^\]]*(?:mso|vml)[^\]]*\]>([\s\S]*?)<!\[endif\]-->/gi, "$1");
9892
+ }
9893
+ function vmlToCss(html) {
9894
+ let out = html;
9895
+ out = out.replace(
9896
+ /<v:roundrect([^>]*)>([\s\S]*?)<\/v:roundrect>/gi,
9897
+ (_m, rawAttrs, inner) => {
9898
+ var _a, _b, _c, _d, _e, _f;
9899
+ const a = attrs(rawAttrs);
9900
+ const s = styleProps((_a = a.style) != null ? _a : "");
9901
+ const w = (_b = px(s.width)) != null ? _b : 0;
9902
+ const h = (_c = px(s.height)) != null ? _c : 0;
9903
+ const radius = a.arcsize ? arcsizeToRadius(a.arcsize, w, h) : 0.2 * (Math.min(w, h) / 2);
9904
+ const fill = (_d = a.fillcolor) != null ? _d : "transparent";
9905
+ const stroked = !(a.stroke === "f" || a.stroke === "false");
9906
+ const middle = /middle/i.test((_e = s["v-text-anchor"]) != null ? _e : "");
9907
+ const label = inner.replace(/<\/?(?:w:anchorlock|center)[^>]*>/gi, "").trim();
9908
+ const box = [
9909
+ w ? `width:${w}px` : "",
9910
+ h ? `height:${h}px` : "",
9911
+ `border-radius:${radius}px`,
9912
+ `background:${fill}`,
9913
+ stroked ? `border:1px solid ${(_f = a.strokecolor) != null ? _f : "#000"}` : "border:none",
9914
+ "display:inline-flex",
9915
+ "justify-content:center",
9916
+ `align-items:${middle ? "center" : "flex-start"}`,
9917
+ "overflow:hidden",
9918
+ "text-align:center"
9919
+ ].filter(Boolean).join(";");
9920
+ const href = a.href ? ` data-href="${a.href}"` : "";
9921
+ return `<div data-vml="roundrect"${href} style="${box}">${label}</div>`;
9922
+ }
9923
+ );
9924
+ out = out.replace(
9925
+ /<v:rect([^>]*)>([\s\S]*?)<\/v:rect>/gi,
9926
+ (_m, rawAttrs, inner) => {
9927
+ var _a, _b, _c, _d, _e, _f;
9928
+ const a = attrs(rawAttrs);
9929
+ const s = styleProps((_a = a.style) != null ? _a : "");
9930
+ const w = px(s.width);
9931
+ const h = px(s.height);
9932
+ const fillTag = inner.match(/<v:fill([^>]*?)\/?>/i);
9933
+ const f = fillTag ? attrs(fillTag[1]) : {};
9934
+ const type = ((_b = f.type) != null ? _b : "solid").toLowerCase();
9935
+ let background = "";
9936
+ if (f.src && type === "tile") {
9937
+ background = `background-image:url('${f.src}');background-repeat:repeat;`;
9938
+ } else if (f.src) {
9939
+ background = `background-image:url('${f.src}');background-size:cover;background-position:center;`;
9940
+ } else if (type === "gradient" || type === "gradientradial") {
9941
+ const from = (_d = (_c = f.color) != null ? _c : a.fillcolor) != null ? _d : "transparent";
9942
+ const to = (_e = f.color2) != null ? _e : from;
9943
+ const angle = f.angle ? `${parseFloat(f.angle)}deg` : "180deg";
9944
+ background = type === "gradientradial" ? `background-image:radial-gradient(${from},${to});` : `background-image:linear-gradient(${angle},${from},${to});`;
9945
+ }
9946
+ const solid = (_f = f.color) != null ? _f : a.fillcolor;
9947
+ const tb = inner.match(/<v:textbox([^>]*)>([\s\S]*?)<\/v:textbox>/i);
9948
+ const inset = tb ? attrs(tb[1]).inset : void 0;
9949
+ const padding = inset ? inset.split(",").map((p) => {
9950
+ var _a2;
9951
+ return `${(_a2 = px(p.trim())) != null ? _a2 : 0}px`;
9952
+ }).join(" ") : "0";
9953
+ const content = tb ? tb[2] : inner.replace(/<v:fill[^>]*?\/?>/gi, "");
9954
+ const box = [
9955
+ w !== void 0 ? `width:${w}px` : "",
9956
+ h !== void 0 ? `height:${h}px` : "",
9957
+ solid ? `background-color:${solid}` : "",
9958
+ `padding:${padding}`
9959
+ ].filter(Boolean).join(";");
9960
+ return `<div data-vml="rect" style="${background}${box}">${content}</div>`;
9961
+ }
9962
+ );
9963
+ return out.replace(/<\/?v:(?:fill|textbox|stroke|shadow|imagedata|path|formulas|handles)[^>]*?\/?>/gi, "");
9964
+ }
9965
+ function renderOutlookBranch(html) {
9966
+ if (!/<!--\[if/i.test(html)) return html;
9967
+ return vmlToCss(resolveMsoBranch(html));
9968
+ }
9969
+
9858
9970
  // src/constants.ts
9859
9971
  var MAX_HTML_SIZE = 2 * 1024 * 1024;
9860
9972
  var MAX_WARNING_LOCATIONS = 100;
@@ -10578,7 +10690,8 @@ function transformForClient(html, clientId, framework) {
10578
10690
  ]
10579
10691
  };
10580
10692
  }
10581
- const downleveled = downlevelCSS(html);
10693
+ const source = clientId === "outlook-windows-legacy" ? renderOutlookBranch(html) : html;
10694
+ const downleveled = downlevelCSS(source);
10582
10695
  return applyTransform(downleveled, config, framework);
10583
10696
  }
10584
10697
  function transformForAllClients(html, framework) {
@@ -13316,9 +13429,9 @@ function mediaApplies(prelude, ctx) {
13316
13429
  const scheme = text.match(/prefers-color-scheme\s*:\s*(dark|light)/);
13317
13430
  if (scheme && scheme[1] === "dark" !== ctx.dark) return false;
13318
13431
  for (const m of text.matchAll(/(max|min)-width\s*:\s*([^)]+)/g)) {
13319
- const px = mediaPx(m[2]);
13320
- if (px === null) continue;
13321
- if (m[1] === "max" ? ctx.width > px : ctx.width < px) return false;
13432
+ const px2 = mediaPx(m[2]);
13433
+ if (px2 === null) continue;
13434
+ if (m[1] === "max" ? ctx.width > px2 : ctx.width < px2) return false;
13322
13435
  }
13323
13436
  return true;
13324
13437
  }
@@ -13539,10 +13652,10 @@ function resolveBackground($, el, cascade) {
13539
13652
  function isLargeText(size, weight) {
13540
13653
  const match = size == null ? void 0 : size.match(/^(\d+(?:\.\d+)?)(px|pt)/i);
13541
13654
  if (!match) return false;
13542
- const px = match[2].toLowerCase() === "pt" ? parseFloat(match[1]) * 1.333 : parseFloat(match[1]);
13655
+ const px2 = match[2].toLowerCase() === "pt" ? parseFloat(match[1]) * 1.333 : parseFloat(match[1]);
13543
13656
  const w = weight == null ? void 0 : weight.trim().toLowerCase();
13544
13657
  const bold = w === "bold" || w === "bolder" || !!w && parseInt(w, 10) >= 700;
13545
- return px >= 18 || px >= 14 && bold;
13658
+ return px2 >= 18 || px2 >= 14 && bold;
13546
13659
  }
13547
13660
  function inherited($, el, cascade, prop) {
13548
13661
  var _a, _b;
@@ -14263,8 +14376,8 @@ function checkTemplateVariablesFromDom($, source) {
14263
14376
  const attrSelectors = ["[href]", "[src]", "[alt]"];
14264
14377
  for (const sel of attrSelectors) {
14265
14378
  $(sel).each((_, el) => {
14266
- const attrs = ["href", "src", "alt"];
14267
- for (const attr2 of attrs) {
14379
+ const attrs2 = ["href", "src", "alt"];
14380
+ for (const attr2 of attrs2) {
14268
14381
  const value = $(el).attr(attr2);
14269
14382
  if (!value) continue;
14270
14383
  for (const [pattern, label] of TEMPLATE_VARIABLE_PATTERNS) {
@@ -14504,9 +14617,9 @@ function locAt(source, offset, length) {
14504
14617
  length
14505
14618
  };
14506
14619
  }
14507
- function attr(attrs, name) {
14620
+ function attr(attrs2, name) {
14508
14621
  var _a;
14509
- const m = attrs.match(
14622
+ const m = attrs2.match(
14510
14623
  new RegExp(`(?:^|\\s)${name}\\s*=\\s*"([^"]*)"|(?:^|\\s)${name}\\s*=\\s*'([^']*)'`, "i")
14511
14624
  );
14512
14625
  return m ? (_a = m[1]) != null ? _a : m[2] : void 0;
@@ -14814,8 +14927,8 @@ function radiusLengths(value) {
14814
14927
  return lengths.filter((v) => {
14815
14928
  if (/^0(?:px|rem|em|%)?$/.test(v)) return false;
14816
14929
  if (v === "50%") return false;
14817
- const px = parseFloat(v);
14818
- return !(v.endsWith("px") && Number.isFinite(px) && px >= 500);
14930
+ const px2 = parseFloat(v);
14931
+ return !(v.endsWith("px") && Number.isFinite(px2) && px2 >= 500);
14819
14932
  });
14820
14933
  }
14821
14934
  function collect($) {
@@ -15221,6 +15334,7 @@ var CompileError = class extends Error {
15221
15334
  analyzeEmail,
15222
15335
  analyzeImages,
15223
15336
  analyzeSpam,
15337
+ arcsizeToRadius,
15224
15338
  auditEmail,
15225
15339
  caveatApplies,
15226
15340
  checkAccessibility,
@@ -15252,6 +15366,8 @@ var CompileError = class extends Error {
15252
15366
  heuristicTokenCount,
15253
15367
  parseColor,
15254
15368
  relativeLuminance,
15369
+ renderOutlookBranch,
15370
+ resolveMsoBranch,
15255
15371
  rgbToOklab,
15256
15372
  simulateDarkMode,
15257
15373
  structuralWarnings,
@@ -15259,6 +15375,7 @@ var CompileError = class extends Error {
15259
15375
  transformForAllClients,
15260
15376
  transformForClient,
15261
15377
  validateLinks,
15378
+ vmlToCss,
15262
15379
  warningsForClient,
15263
15380
  wcagGrade
15264
15381
  });