Skip to content

Latest commit

 

History

History
899 lines (665 loc) · 37.4 KB

File metadata and controls

899 lines (665 loc) · 37.4 KB

API Reference

Back to README

Table of Contents


Core

auditEmail(html: string, options?: AuditOptions): AuditReport

Unified API: runs all 8 email analysis checks in a single call. Returns compatibility warnings + scores, spam analysis, link validation, accessibility audit, image analysis, inbox preview extraction, size checking, and template variable detection.

Internally parses the HTML once and shares the DOM across all analyzers.

import { auditEmail } from "@emailens/engine";

const report = auditEmail(html, {
  framework: "jsx",         // attach framework-specific fix snippets
  spam: { emailType: "transactional" },  // skip unsubscribe check
  skip: ["images"],         // skip specific checks
});

// report.compatibility.warnings  — CSSWarning[]
// report.compatibility.scores    — Record<string, ClientScore>
// report.spam                    — SpamReport
// report.links                   — LinkReport
// report.accessibility           — AccessibilityReport
// report.images                  — ImageReport
// report.inboxPreview            — InboxPreview
// report.size                    — SizeReport
// report.templateVariables       — TemplateReport
// report.overflow                — OverflowReport
// report.visual                  — VisualReport

AuditOptions:

  • framework?: "jsx" | "mjml" | "maizzle": attach framework-specific fix snippets
  • spam?: SpamAnalysisOptions: options for spam analysis
  • skip?: Array<"spam" | "links" | "accessibility" | "images" | "compatibility" | "inboxPreview" | "size" | "templateVariables">, skip specific checks
  • positions?: boolean: record source positions, so issues carry a loc (Source positions)

createSession(html: string, options?: CreateSessionOptions): EmailSession

Session API: pre-parses the HTML once and exposes all analysis methods on the shared DOM. Use this when you need to call multiple analysis functions on the same HTML to avoid redundant parsing.

import { createSession } from "@emailens/engine";

const session = createSession(html, { framework: "jsx" });

// All analysis methods share a single DOM parse:
const warnings = session.analyze();
const spam = session.analyzeSpam();
const links = session.validateLinks();
const a11y = session.checkAccessibility();
const images = session.analyzeImages();
const preview = session.extractInboxPreview();
const size = session.checkSize();
const templates = session.checkTemplateVariables();
const overflow = session.checkOverflow();
const visual = session.checkVisual();

// Or run everything at once:
const report = session.audit();

CreateSessionOptions:

  • framework?: "jsx" | "mjml" | "maizzle": framework for fix snippets (applies to all session methods)
  • positions?: boolean: record source positions, so issues carry a loc (Source positions)

EmailSession methods:

Method Shares DOM Description
audit(options?) Yes Run all checks (equivalent to auditEmail)
analyze() Yes CSS compatibility warnings
analyzeSpam(options?) Yes Spam indicator analysis
validateLinks() Yes Link validation
checkAccessibility() Yes Accessibility audit
analyzeImages() Yes Image analysis
extractInboxPreview() Yes Subject line and preheader extraction
checkSize() Yes Gmail clipping size check
checkTemplateVariables() Yes Unresolved template variable detection
checkOverflow() Yes Content overflow (fixed widths, unbreakable strings)
checkVml() Yes Structural faults in Outlook-only VML
checkVisual() Yes Visual bugs in stylized emails (background/font fallbacks)

When to use sessions vs standalone functions:

  • Multiple analysis calls on the same HTML → use createSession() to avoid redundant parsing
  • Single analysis call → use standalone functions (auditEmail, analyzeEmail, etc.)
  • Server-side batch processing → use createSession() per email for best throughput

Source positions

Pass positions: true and every issue that belongs to a specific node carries a loc: enough to underline it in an editor, annotate it on a pull request, or hand an agent the exact edit site.

const report = auditEmail(html, { positions: true });

for (const issue of report.links.issues) {
  if (issue.loc) console.log(`${file}:${issue.loc.line}:${issue.loc.column}  ${issue.message}`);
}
// emails/welcome.html:11:6  Link uses HTTP instead of HTTPS

const w = report.compatibility.warnings.find((w) => w.property === "border-radius");
html.slice(w.loc.offset, w.loc.offset + w.loc.length);   // "border-radius: 8px"
interface SourceLocation {
  line: number;       // 1-based, in the original HTML string
  column: number;     // 1-based
  endLine: number;
  endColumn: number;
  offset: number;     // 0-based character offset
  length: number;
}

Every occurrence, not just the first. A CSS warning covers a property, and one property can break in many places. loc is the first; locs lists them all in document order, so an editor can flag every offender and a fix can be applied everywhere:

const w = report.compatibility.warnings.find((w) => w.property === "border-radius");
w.loc;            // first occurrence
w.locs;           // [{ line: 4, … }, { line: 9, … }, { line: 14, … }]
w.locsTruncated;  // true if there were more than 100 and the list is partial

Warnings are deduplicated per client, property, severity and selector, so elements the analyzer describes differently (div.card vs span) produce separate warnings for the same property. To reach every place a property breaks, union locs across the warnings for it:

const everywhere = report.compatibility.warnings
  .filter((w) => w.property === "border-radius" && w.client === "outlook-windows")
  .flatMap((w) => w.locs ?? []);

Available on every BaseIssue (spam, links, accessibility, images, inbox preview, size, template variables, overflow, visual) and on CSSWarning. It is also accepted by the standalone analyzers: analyzeEmail(html, framework, { positions: true }), validateLinks(html, { positions: true }), and the same for checkAccessibility, analyzeImages, and checkTemplateVariables.

What each finding anchors to

Finding Anchor
CSS property in a <style> block the declaration, border-radius: 8px
CSS property in an inline style the declaration, font-size: 1rem, not the whole style="…"
Unsupported HTML feature (<style>, <svg>, <form>) the first element that triggered it
Link, image, accessibility finding about one attribute that attribute: href="http://…"
Link, image, accessibility finding about an element the opening tag: <img src="…">
Template variable in text the variable itself: {{first_name}}
Template variable in an attribute the attribute holding it
At-rule (@media, @font-face) the rule that triggered it
Fixed-width overflow the width attribute or style that set it
Visual finding (gradient, font stack) the declaration that caused it
Unbreakable string the string itself
Dark-mode coverage the bgcolor or style keeping the element light

What has no position

  • Document-level findings: Gmail clipping, aggregate image counts, heading hierarchy summaries, most spam signals. loc is undefined, handle that.
  • Elements the parser synthesized rather than read from the source (an implicit <head> in a fragment); there is no source to point at.
  • Findings that describe a kind of problem rather than one element (CSS warnings, overflow, visual) carry every occurrence in locs (first in loc), capped at 100 with locsTruncated: true when the list is partial. Analyzers that already emit one issue per element carry loc alone.

Caveats

  • Positions describe the HTML that was analyzed. For MJML, Maizzle, or React Email, that is the compiled HTML, not your source file.
  • Character references and CRLF line endings are resolved against the original source, so &amp; or &#8212; earlier in a line does not shift a position. The one case that still falls back to the containing node is a token that was itself encoded ({{a&amp;b}}).
  • CSSWarning.line is deprecated in favour of loc. Without positions it remains the line within the <style> block; with positions it is the document line.

Cost. It grows with the document: about +6–14% on a full auditEmail() at typical email size (~10KB), +30% at Gmail's ~102KB clipping limit, and +79% on a 450KB document; locating a finding means walking text and CSS the analyzers would otherwise skim. Measure on your own fixtures with bun run bench:positions.


Standalone Analysis

analyzeEmail(html: string, framework?: Framework): CSSWarning[]

Analyzes an HTML email and returns CSS compatibility warnings for all 21 email clients. Detects <style>, <link>, <svg>, <video>, <form>, inline CSS properties, @font-face, @media queries, gradients, flexbox/grid, and more.

The optional framework parameter controls which fix snippets are attached to warnings. Analysis always runs on compiled HTML.

const warnings = analyzeEmail(html);          // Plain HTML
const warnings = analyzeEmail(html, "jsx");   // React Email fixes
const warnings = analyzeEmail(html, "mjml");  // MJML fixes

generateCompatibilityScore(warnings): Record<string, ClientScore>

Generates a 0–100 compatibility score per email client. Formula: 100 - (errors × 10) - (warnings × 3), clamped to 0–100, counting distinct properties rather than occurrences so one mistake repeated in twelve elements costs what it costs once.

Partial support (info) is not scored. It is counted and returned in ClientScore.info, but it does not move the number; a property that mostly works is not a defect. A finding also becomes info when a fallback this client can see still carries that layout. The finding stays on the report.


Spam & Deliverability

analyzeSpam(html: string, options?: SpamAnalysisOptions): SpamReport

Analyzes an HTML email for spam scoring issues. Returns a 0–100 score (100 = clean) and an array of issues. Uses heuristic rules modeled after SpamAssassin, CAN-SPAM, and GDPR.

Note: Spam scoring heuristics, not a real spam filter. This checks for common anti-patterns that trigger spam filters but cannot predict actual inbox placement. For real spam testing, use the checkSpamAssassin() integration or a dedicated service.

import { analyzeSpam } from "@emailens/engine";

const report = analyzeSpam(html, {
  emailType: "transactional",       // skip unsubscribe check
  listUnsubscribeHeader: "...",     // satisfies unsubscribe requirement
});
// { score: 95, level: "low", issues: [...] }

Checks: caps ratio, excessive punctuation, spam trigger phrases, missing unsubscribe link (with transactional email exemption), hidden text, URL shorteners, image-to-text ratio, deceptive links (with ESP tracking domain allowlist), all-caps subject.

checkDeliverability(domain, options?): Promise<DeliverabilityReport>

Validates email deliverability for a domain by checking MX, SPF, DKIM, DMARC, and BIMI DNS records. All DNS queries have a 5-second timeout. No external dependencies, uses node:dns/promises.

import { checkDeliverability } from "@emailens/engine";

const report = await checkDeliverability("example.com");
console.log(report.score);   // 0-100
console.log(report.checks);  // individual check results
console.log(report.issues);  // actionable issues

Checks:

  • MX: domain can receive email
  • SPF: authorized senders (v=spf1), flags dangerous +all
  • DKIM: probes 15 common selectors (google, selector1, default, dkim, etc.)
  • DMARC: policy enforcement (v=DMARC1), warns on p=none
  • BIMI: brand indicator (optional, nice-to-have)

Also available as a session method: session.checkDeliverability("example.com").

Note: This is standalone async, not wired into the synchronous auditEmail() pipeline.

checkSpamAssassin(input, options?): Promise<SpamAssassinResult | null>

Opt-in integration with a local SpamAssassin installation. Shells out to spamc (daemon) or spamassassin (standalone) via execFile. Returns null if SpamAssassin is not installed.

import { checkSpamAssassin } from "@emailens/engine";

const result = await checkSpamAssassin(rawRfc2822Message);
if (result) {
  console.log(result.score);      // e.g. 3.2
  console.log(result.isSpam);     // true if score >= threshold
  console.log(result.rules);      // matched SpamAssassin rules
}

Note: Requires a full RFC 2822 message (headers + body), not just HTML.


Content Analysis

validateLinks(html: string): LinkReport

Static analysis of all links in an HTML email. No network requests.

import { validateLinks } from "@emailens/engine";

const report = validateLinks(html);
// { totalLinks: 12, issues: [...], breakdown: { https: 10, http: 1, mailto: 1, ... } }

Checks: empty/placeholder hrefs, javascript: protocol, insecure HTTP, generic link text, missing accessible names, empty mailto/tel, very long URLs, duplicate links.

checkAccessibility(html: string): AccessibilityReport

Audits an HTML email for accessibility issues. Returns a 0–100 score and detailed issues.

import { checkAccessibility } from "@emailens/engine";

const report = checkAccessibility(html);
// { score: 88, issues: [...] }

Checks: missing lang attribute, missing <title>, image alt text, link accessibility, layout table roles, small text, color contrast (WCAG 2.1), heading hierarchy.

analyzeImages(html: string): ImageReport

Analyzes images for email best practices.

import { analyzeImages } from "@emailens/engine";

const report = analyzeImages(html);
// { total: 5, totalDataUriBytes: 0, issues: [...], images: [...] }

Checks: missing dimensions, oversized data URIs, missing alt, WebP/SVG format, missing display:block, tracking pixels, high image count.

extractInboxPreview(html: string): InboxPreview

Extracts subject line (from <title>) and preheader text from the email HTML. Returns per-client truncation data showing how subject and preheader will appear across 8 email clients.

import { extractInboxPreview } from "@emailens/engine";

const preview = extractInboxPreview(html);
// { subject: "Newsletter", preheader: "This week's highlights...",
//   subjectLength: 10, preheaderLength: 28,
//   truncation: [...], issues: [...] }

Checks: missing <title>, subject too long, missing preheader, preheader too short/long, &zwnj;&nbsp; padding hack, emoji in subject.

checkSize(html: string): SizeReport

Checks email HTML byte size for Gmail clipping issues. Gmail clips messages larger than ~102KB, hiding content behind a "View entire message" link.

import { checkSize } from "@emailens/engine";

const report = checkSize(html);
// { htmlBytes: 45230, humanSize: "44.2 KB", clipped: false, issues: [] }

Checks: Gmail clipping threshold (102KB), approaching clip threshold warning (90KB).

checkTemplateVariables(html: string): TemplateReport

Scans email HTML for unresolved template/merge variables in text content and key attributes (href, src, alt).

import { checkTemplateVariables } from "@emailens/engine";

const report = checkTemplateVariables(html);
// { unresolvedCount: 0, issues: [] }

Detects: {{var}} (Handlebars/Mustache), ${var} (ES template literals), <%= %> (ERB/EJS), *|TAG|* (Mailchimp), %%tag%% (Salesforce), {merge_field} (single-brace).

checkOverflow(html: string): OverflowReport

Detects content likely to overflow the email frame or mobile viewport: a client-agnostic layout check. Scans inline styles and <style> rules (incl. inside @media).

import { checkOverflow } from "@emailens/engine";

const report = checkOverflow(html);
// { hasOverflow: true, issues: [{ rule: "fixed-width-overflow", severity: "warning", message: "…", detail: "…" }] }

Detects: fixed pixel widths wider than the email frame with no width:100%/max-width:100% escape (fixed-width-overflow); long unbreakable strings (raw URLs, tokens) that can't wrap (unbreakable-string, skipped when the email already uses overflow-wrap/word-break).

checkVisual(html: string): VisualReport

Detects probable visual bugs in stylized emails; graceful-degradation failures that render visibly wrong. Each issue carries a concrete fix. Scans inline styles and <style> rules.

import { checkVisual } from "@emailens/engine";

const report = checkVisual(html);
// issues: [{ rule: "missing-background-fallback", severity: "warning", message: "…", fix: "background-color: #2d1b4e;" }]

Detects: background images/gradients with no solid background-color; blank area (and hidden text) in Outlook (missing-background-fallback, gradient fallback computed from the first color stop); font-family stacks with no web-safe fallback; Times New Roman in Gmail/Outlook (missing-font-fallback, web-safe family appended).

checkVml(html: string, options?: { positions?: boolean }): VmlReport

Validates the Outlook-only markup inside <!--[if mso]> conditional comments. This is the one section of an email the DOM analyzers structurally cannot reach: to every HTML parser the VML is a comment node, and to a headless-Chromium screenshot it does not exist at all, so an email can lint clean and preview perfectly while the branch Outlook actually renders is broken.

Takes raw HTML rather than a DOM, and reads tags as a document-order sequence rather than a parsed fragment, because a single shape routinely opens in one conditional block and closes in another with ordinary HTML in between.

import { checkVml } from "@emailens/engine";

const report = checkVml(html, { positions: true });
// { hasVml: true, issues: [{ rule: "vml-nested-shape", severity: "error", message: "…", detail: "…", loc: {…} }] }

Detects, all four verified against Outlook Classic (the Word engine) rather than inferred:

Rule Severity What Outlook actually does
vml-nested-shape error A shape inside another shape's <v:textbox>. Three failures at once: the containing shape does not render, the table structure around it terminates early so content after it falls outside the email frame, and every VML shape further down the email stops drawing its text. <v:group> is exempt, being the one container VML defines for the purpose.
vml-invalid-dimension error A dimension whose number went missing (height:px), typically a template variable that resolved empty. The shape still draws, at a size Outlook picks, silently clipping the content inside.
vml-unrendered-text error Label text with no element around it. The shape renders its fill and none of the text, so a button ships as a blank coloured block. <center> or <v:textbox> fixes it.
vml-arcsize-range warning An arcsize outside the documented 0%–100%. Outlook clamps it, so 120% draws the identical corner to 100%: nothing breaks, but the radius is the renderer's clamp rather than a chosen value.

Also reports vml-unbalanced-tag for a shape opened and never closed, or closed and never opened.

hasVml is false for any email with no VML at all, which is most of them; consumers can skip the section entirely rather than render an empty result.

checkDesignConsistency(html: string): DesignReport

Reports design incoherence rather than rendering failure: values that differ but read as one choice, and sets of values too large to be a system.

import { checkDesignConsistency } from "@emailens/engine";

const report = checkDesignConsistency(html);
// issues: [{
//   rule: "colour-drift",
//   severity: "info",
//   message: "2 near-identical colours are used where one was probably meant: rgb(234, 230, 222), rgb(240, 236, 228).",
//   detail: "These render as the same colour to a reader, so the difference is drift rather than a choice. …",
//   values: ["rgb(234, 230, 222)", "rgb(240, 236, 228)"],
// }]

Colours are reported in rgb() form, whatever notation the source used, so #fff, #FFFFFF and white compare as one value.

Detects: near-identical colours (colour-drift, OKLab distance under 0.02, below the point a reader can tell them apart); more than 8 font sizes, 2 typefaces or 3 corner radii (too-many-values). A radius shorthand normalises as a shape, so 12px 12px 0 0 and 0 0 12px 12px are one radius, and 50%/pill values are excluded; a font stack counts as its first family.


checkDarkModeContrast(html: string): AccessibilityIssue[]

Grades text contrast in the simulated dark-mode renders, where a hardcoded light background meets text the email's dark block never re-colours.

import { checkDarkModeContrast } from "@emailens/engine";

const issues = checkDarkModeContrast(html);
// [{ rule: "low-contrast-dark", severity: "error",
//    message: "Dark mode: Low contrast ratio 1.3:1, fails WCAG minimum",
//    element: "<h1>Thanks for your order</h1>", details: "…" }]

Covers the clients that force an inversion. Runs both Gmail Android (partial) and Gmail iOS (full), because they disagree: a colour just under Android's lightness threshold is left alone there and repainted on iOS.


checkDarkStylesContrastFromDom($: CheerioAPI): AccessibilityIssue[]

Grades the email's own @media (prefers-color-scheme: dark) block as the clients that honour it apply it (Apple Mail, Superhuman, Thunderbird). This is the other dark-mode failure: a dark block that repaints a surface but does not re-colour every text layer sitting on it.

auditEmail merges this with checkDarkModeContrast into report.darkContrast, deduplicated per element.


checkMobileContrast(html: string): AccessibilityIssue[]

Grades text contrast at 375px, so a colour pairing that only exists inside a max-width block is checked rather than assumed to match the desktop palette.



Transforms & Dark Mode

transformForClient(html, clientId, framework?): TransformResult

Transforms HTML for a specific email client: strips unsupported CSS, inlines <style> blocks (for Gmail), removes unsupported elements.

transformForAllClients(html, framework?): TransformResult[]

Transforms HTML for all 21 email clients at once.

simulateDarkMode(html, clientId): { html, warnings }

Simulates how an email client applies dark mode using luminance-based color detection.

  • Full inversion (Gmail iOS, Outlook Classic, Thunderbird¹): inverts all light backgrounds and dark text
  • Partial inversion (Gmail Android, Outlook.com, Outlook (New), Outlook iOS, Outlook Android, Samsung Mail², HEY, Superhuman): only inverts very light/dark colors
  • Respects prefers-color-scheme (Apple Mail macOS/iOS): no forced inversion; honors @media (prefers-color-scheme: dark) if present
  • No content inversion (Gmail Web, Yahoo Mail): only the email client UI is darkened

¹ Thunderbird skips inversion when prefers-color-scheme is present in the email. ² Samsung Mail skips inversion when prefers-color-scheme is present in the email.

getCodeFix(property, clientId, framework?): CodeFix | undefined

Returns a paste-ready code fix for a CSS property + client combination. Fixes are tiered:

  1. Framework + client specific (e.g., border-radius + Outlook + JSX → VML component)
  2. Framework specific (e.g., @font-face + MJML → <mj-font>)
  3. Client specific (e.g., border-radius + Outlook → VML roundrect)
  4. Generic HTML fallback

diffResults(before, after): DiffResult[]

Compares two sets of analysis results to show what improved, regressed, or stayed the same.


Compile Module

Compile email templates from JSX, MJML, or Maizzle to HTML.

Each compiler is an optional peer dependency, installed by you rather than by us; email projects rarely use more than one, and MJML alone pulls in 56MB. The versions these are tested against:

Format Package Supported
mjml mjml >=4.0.0, tested on 5.x
jsx @react-email/render, @react-email/components, react >=1.0.0 / >=0.0.36, tested on 2.x / 1.x
maizzle @maizzle/framework >=5.0.0 <7.0.0

Maizzle 5 compiles an HTML string. Maizzle 6 compiles a Vue single-file component: pass the .vue file contents, including <template>. detectFormat maps .vue to maizzle. A Vue <script> runs during compile, the same way the Maizzle CLI does. The source may not import, re-export, or point an SFC src at another file, and component lookup does not use the working directory.

import { compile, detectFormat, CompileError } from "@emailens/engine/compile";

// Auto-detect format and compile
const format = detectFormat("email.tsx");  // "jsx"
const html = await compile(source, format);

// Or use specific compilers
import { compileReactEmail, compileMjml, compileMaizzle } from "@emailens/engine/compile";

compile(source, format, filePath?): Promise<string>

Compile source to HTML based on format. Lazily imports per-format compilers.

compileReactEmail(source, options?): Promise<string>

Compile React Email JSX/TSX to HTML. Pipeline: validate → transpile (sucrase) → sandbox execute → render.

import { compileReactEmail } from "@emailens/engine/compile";

const html = await compileReactEmail(jsxSource, {
  sandbox: "isolated-vm",  // "vm" | "isolated-vm"
});

Sandbox strategies:

  • "isolated-vm" (default): Separate V8 isolate. True heap isolation. Requires isolated-vm native addon.
  • "vm": node:vm with hardened globals. Fast, zero-dependency, but NOT a true security boundary. Suitable for CLI/local use.

Peer dependencies: sucrase, react, @react-email/components, @react-email/render. Plus isolated-vm for the default sandbox.

compileMjml(source): Promise<string>

Compile MJML to HTML. Peer dependency: mjml.

compileMaizzle(source): Promise<string>

Compile Maizzle template to HTML. Peer dependency: @maizzle/framework.

Security: PostHTML file-system directives (<extends>, <component>, <fetch>, <include>, <module>, <slot>, <fill>, <raw>, <block>, <yield>) are rejected at validation time to prevent server-side file reads.

detectFormat(filePath): InputFormat

Auto-detect input format from file extension (.tsx/.jsx → "jsx", .mjml → "mjml", .vue → "maizzle", .html → "html").

CompileError

Unified error class for all compilation failures. Available from both @emailens/engine and @emailens/engine/compile.

import { CompileError } from "@emailens/engine";

try {
  await compile(source, "jsx");
} catch (err) {
  if (err instanceof CompileError) {
    console.log(err.format);  // "jsx" | "mjml" | "maizzle"
    console.log(err.phase);   // "validation" | "transpile" | "execution" | "render" | "compile"
  }
}

AI-Powered Fixes

The engine classifies every warning as either css (CSS-only swap) or structural (requires HTML restructuring). For structural issues, the engine can generate a prompt and delegate to an LLM.

generateAiFix(options): Promise<AiFixResult>

Also accepts optional overflow and visual arrays (from checkOverflow() / checkVisual()), when passed, their findings and concrete fixes are folded into the prompt so the fixer repairs layout/visual bugs too, not just per-property compatibility warnings. Same options apply to generateFixPrompt().

import { generateAiFix, AI_FIX_SYSTEM_PROMPT } from "@emailens/engine";

const result = await generateAiFix({
  originalHtml: html,
  warnings,
  scores,
  scope: "all",
  format: "jsx",
  provider: async (prompt) => {
    const msg = await anthropic.messages.create({
      model: "claude-sonnet-4-6",
      max_tokens: 8192,
      system: AI_FIX_SYSTEM_PROMPT,
      messages: [{ role: "user", content: prompt }],
    });
    return msg.content[0].type === "text" ? msg.content[0].text : "";
  },
});

estimateAiFixTokens(options): Promise<TokenEstimate>

Estimate tokens before making an API call.

heuristicTokenCount(text): number

Instant synchronous token estimate (~3.5 chars/token).


Performance

Shared DOM parsing

The engine internally parses HTML using Cheerio. For a typical 50–100KB email, each cheerio.load() call takes 5–15ms. Without optimization, calling multiple analysis functions on the same HTML would parse it repeatedly.

auditEmail() parses the HTML once and shares the DOM across all 8 analyzers (compatibility, spam, links, accessibility, images, inbox preview, size, template variables). Previously each analyzer parsed independently: this eliminates ~80% of parsing overhead in the audit path.

createSession() extends this optimization to any combination of calls. When you need to call analyzeEmail() + analyzeSpam() + validateLinks() + other checks on the same HTML, a session shares a single parse across all of them.

Typical performance characteristics

Operation Complexity Notes
auditEmail() 1 parse + 8 analyses Shared DOM, most efficient for full reports
createSession() 1 parse upfront Amortized across all subsequent analysis calls
analyzeEmail() 1 parse + CSS property scan Scans <style> blocks + inline styles × 21 clients
transformForAllClients() 21 parses (1 per client) Each client mutates its own DOM copy
simulateDarkMode() 1 parse per call Mutates DOM for color inversion

Optimization tips for consumers

// Instead of this (6 separate HTML parses):
const warnings = analyzeEmail(html, "jsx");
const scores = generateCompatibilityScore(warnings);
const spam = analyzeSpam(html);
const links = validateLinks(html);
const a11y = checkAccessibility(html);
const images = analyzeImages(html);

// Do this (1 HTML parse):
const report = auditEmail(html, { framework: "jsx" });

// Or for selective analysis (1 HTML parse):
const session = createSession(html, { framework: "jsx" });
const warnings = session.analyze();
const spam = session.analyzeSpam();
// ... pick only what you need

Security Considerations

Input Size Limits

All public functions enforce a 2MB (MAX_HTML_SIZE) input limit. Inputs exceeding this limit throw immediately. The limit is exported so consumers can check before calling:

import { MAX_HTML_SIZE } from "@emailens/engine";
if (html.length > MAX_HTML_SIZE) {
  // handle oversized input
}

Compile Module Security

  • React Email JSX: User code runs in a sandboxed environment. The "isolated-vm" strategy provides true heap isolation. The "vm" strategy uses node:vm, which is NOT a security boundary; suitable for CLI use where users run their own code. For server deployments accepting untrusted input, use "isolated-vm".
  • Maizzle: PostHTML directives that access the filesystem (<extends>, <fetch>, <include>, <raw>, <block>, <yield>, etc.) are rejected at validation time.
  • MJML: Compiled through the mjml package with default settings.

Types

type SupportLevel = "supported" | "partial" | "unsupported" | "unknown";
type Framework = "jsx" | "mjml" | "maizzle";
type InputFormat = "html" | Framework;
type FixType = "css" | "structural";

interface SourceLocation {
  line: number;        // 1-based line in the analyzed HTML
  column: number;      // 1-based column
  endLine: number;
  endColumn: number;
  offset: number;      // 0-based character offset
  length: number;
}

interface BaseIssue {          // every analyzer's issues extend this
  rule: string;
  severity: "error" | "warning" | "info";
  message: string;
  loc?: SourceLocation;        // requires `positions: true`
}

interface CSSWarning {
  severity: "error" | "warning" | "info";
  client: string;
  property: string;
  message: string;
  suggestion?: string;
  fix?: CodeFix;
  fixType?: FixType;
  line?: number;             // deprecated — use `loc`
  selector?: string;         // element selector for inline styles
  loc?: SourceLocation;      // first occurrence; requires `positions: true`
  locs?: SourceLocation[];   // every occurrence, in document order
  locsTruncated?: boolean;   // more than MAX_WARNING_LOCATIONS (100) occurrences
}

interface AuditReport {
  compatibility: {
    warnings: CSSWarning[];
    scores: Record<string, { score: number; errors: number; warnings: number; info: number }>;
  };
  spam: SpamReport;
  links: LinkReport;
  accessibility: AccessibilityReport;
  images: ImageReport;
  inboxPreview: InboxPreview;
  size: SizeReport;
  templateVariables: TemplateReport;
}

interface EmailSession {
  readonly html: string;
  readonly framework: Framework | undefined;
  audit(options?): AuditReport;
  analyze(): CSSWarning[];
  analyzeSpam(options?): SpamReport;
  validateLinks(): LinkReport;
  checkAccessibility(): AccessibilityReport;
  analyzeImages(): ImageReport;
  extractInboxPreview(): InboxPreview;
  checkSize(): SizeReport;
  checkTemplateVariables(): TemplateReport;
}

interface InboxPreview {
  subject: string | null;
  preheader: string | null;
  subjectLength: number;
  preheaderLength: number;
  truncation: ClientTruncation[];
  issues: InboxPreviewIssue[];
}

interface SizeReport {
  htmlBytes: number;
  humanSize: string;
  clipped: boolean;
  issues: SizeIssue[];
}

interface TemplateReport {
  unresolvedCount: number;
  issues: TemplateIssue[];
}

interface SpamReport {
  score: number;       // 0–100 (100 = clean)
  level: "low" | "medium" | "high";
  issues: SpamIssue[];
}

interface LinkReport {
  totalLinks: number;
  issues: LinkIssue[];
  breakdown: { https: number; http: number; mailto: number; tel: number; ... };
}

interface AccessibilityReport {
  score: number;       // 0–100
  issues: AccessibilityIssue[];
}

interface ImageReport {
  total: number;
  totalDataUriBytes: number;
  issues: ImageIssue[];
  images: ImageInfo[];
}

interface DeliverabilityReport {
  domain: string;
  checks: DeliverabilityCheck[];
  score: number;       // 0-100
  issues: DeliverabilityIssue[];
}

interface DeliverabilityCheck {
  name: "spf" | "dkim" | "dmarc" | "mx" | "bimi";
  status: "pass" | "fail" | "warn" | "skip";
  message: string;
  detail?: string;
  record?: string;
}

interface SpamAssassinResult {
  score: number;
  threshold: number;
  isSpam: boolean;
  rules: Array<{ name: string; score: number; description: string }>;
  rawOutput: string;
}