Email Design skill for Claude Code

HTML email that survives Outlook, Gmail and dark mode. Claude builds emails for the worst renderer first: role=presentation tables, inlined CSS, width attributes on every image, a hidden preheader and a VML-safe button, then layers dark-mode and mobile styles on top. A bundled stdlib linter checks the compiled HTML for external CSS, flex layouts, missing alt or lang, and the size at which Gmail clips the footer.

  • nameemail-design
  • size240 lines · ≈3,050 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Designs and builds HTML emails that render in Gmail, Apple Mail, Outlook (classic Word engine and new web-based), Yahoo and mobile clients — table-based fluid layouts at 600px, inlined CSS, bulletproof buttons, images-off resilience, dark mode that survives forced inversion, accessible semantics, and MJML 5 or React Email 5 workflows with a lint script for size and markup. Use when building or fixing an email template, an email looks broken in Outlook or dark mode, Gmail clips a message, or choosing between MJML, React Email and Maizzle.

Try asking

  • Our receipt email looks broken in Outlook for Windows, fix the template
  • Build a React Email template for the weekly digest that works in Gmail dark mode
  • Gmail is clipping our newsletter, find out what is bloating the HTML

Use it when

  • Building or redesigning an HTML email template
  • An email renders wrong in Outlook, Gmail or dark mode
  • Gmail shows [Message clipped] on your emails
  • Choosing between MJML, React Email and Maizzle

Skip it when

  • You need sending infrastructure, SPF/DKIM or bounce handling, use transactional-email instead
  • You are planning a drip sequence or writing subject lines, use email-campaigns instead

How to check the result

The lint script reports zero errors on the compiled HTML, and previews in classic Outlook, Gmail iOS dark mode and Apple Mail show the message and button intact with images on and off.

The common mistake it prevents

Putting the headline and offer inside a hero image, so the email is blank with images off and unreadable after Gmail's forced dark-mode inversion.

~/.claude/skills/email-design/SKILL.md

--- name: email-design description: Designs and builds HTML emails that render in Gmail, Apple Mail, Outlook (classic Word engine and new web-based), Yahoo and mobile clients… license: MIT ---

A finished email reads correctly with images off, in classic Outlook for Windows, in a Gmail dark-mode app, at 320px wide, and through a screen reader; its compiled HTML is under 102 KB; and every visual decision survives a client that ignores half your CSS. Design for the worst renderer first, then enhance.

Scope

This skill owns layout, visual design, markup and rendering. Sending infrastructure, DNS and webhooks belong to transactional-email; sequence strategy, subject lines and copy belong to email-campaigns.

Step 0 — inspect what exists

ls emails/ templates/email* src/emails 2>/dev/null       # where templates live
grep -l "mjml\|@react-email\|maizzle" package.json         # which toolchain
grep -rn "<mj-\|from \"@react-email" --include=*.{mjml,tsx,jsx} . | head

Find the compiled output (what actually ships). Lint and test the compiled HTML, never the source template.

Pick the toolchain

SituationUse
React/TS codebase, devs maintain templates, Resend or similarReact Email 5 (react-email + @react-email/components, keep versions in sync)
Mixed stack, designers touch templates, need proven Outlook outputMJML 5 (npx mjml in.mjml -o out.html)
Team already thinks in Tailwind, wants full control of HTMLMaizzle
One-off template in an ESP’s drag-and-drop editorHand-edit the ESP’s HTML; still run the lint and client tests
Hand-written tables from scratchOnly for a tiny template; you will reinvent MSO conditionals badly

React Email 5 notes: render() is async (const html = await render(<Invoice {...props} />)); renderAsync was removed. Pass { plainText: true } to produce the text part. Tailwind 4 classes are supported via the Tailwind component and inlined on elements, not on custom components — verify the compiled output, not the JSX.

MJML 5 notes: components are the same as v4 (mj-section, mj-column, mj-button, mj-image); minification and beautification options changed across 5.x releases, so check npx mjml --help for the installed version instead of copying v4 flags.

Hard layout rules

  • Width: content container 600px (max 640px), fluid below that. Use the hybrid pattern: width="600" attribute plus style="width:100%;max-width:600px", with an MSO conditional ghost table so classic Outlook gets a fixed width.
  • Tables for structure. <table role="presentation" cellpadding="0" cellspacing="0" border="0"> for every layout table. <div> is fine for wrappers and text blocks, never for columns.
  • No flex, grid, float layouts, position, or CSS columns in structure. Classic Outlook (Word engine) ignores them.
  • Columns: at most 2 on mobile-first designs; 3 only for small icon/stat rows. Stack under 480px using display:inline-block columns with max-width (fluid hybrid) so stacking works even where media queries are stripped.
  • Spacing: padding on <td>, not margin (Outlook drops margin on many elements). Use an 8px spacing scale: 8, 16, 24, 32, 48.
  • CSS: inline everything that matters. Keep a <style> block only for media queries, dark mode and client resets, under ~16 KB, and syntactically perfect — Gmail discards the whole block on one parse error.
  • Size: keep compiled HTML under ~90 KB; Gmail clips above ~102 KB and hides everything below the fold, including the unsubscribe link and tracking pixel.

Visual decisions that avoid the template look

Generic marketing email: logo centered, hero banner image with baked-in text, three icon columns, giant rounded button, grey footer wall. Replace with:

DefaultDo instead
Hero image with headline text inside the JPGLive-text headline (24–32px, bold) set in HTML; image supports it, never carries it
Three icon+blurb columnsOne clear message and one action per email; secondary items as a short linked list
Full-width gradient bannerSolid brand color block or a real product screenshot cropped to the relevant part
Centered everythingLeft-aligned body text (centered only for single-line headlines and the CTA in short emails)
Pill button at 60px tallButton height 44–48px, padding 12–14px 24px, radius matching the product UI (often 4–8px)
Footer: eight social iconsAddress, why they got it, preference and unsubscribe links, at most 2 social links

Transactional emails (receipts, resets, alerts) should look like the product UI, not like a newsletter: the key fact (amount, code, status) in the first 100px, in text large enough to read in the inbox preview pane.

Typography

  • Body 16px (never under 14px), line-height 1.5, max ~70 characters per line at 600px with 24–32px side padding.
  • Font stacks: web fonts load only in Apple Mail, iOS Mail and some others; Gmail and classic Outlook ignore them. Always end with a system stack: font-family: "Inter", -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  • Classic Outlook falls back to Times New Roman when the first family is a web font it cannot load. Wrap the web-font declaration in a <!--[if !mso]><!--> block or set the MSO fallback via a conditional style with font-family: Arial, sans-serif.
  • Use real <h1>/<h2>/<p> with inline sizes and margins — they give screen readers structure. Reset margins explicitly (margin:0 0 16px).

Images

  • Absolute https:// URLs on a domain you control or a CDN; no data: URIs (blocked by Gmail and Outlook) and no SVG (unsupported in Gmail and Outlook) — export PNG/JPG.
  • Always set the width attribute (classic Outlook ignores CSS width on images and renders at native size), plus style="display:block;max-width:100%;height:auto".
  • Export at 2x and set the display width via attribute. Keep each image under ~200 KB and the email’s total images under ~1 MB.
  • Every image gets alt. Meaningful images: describe the content or the action. Decorative: alt="". Style alt text (style="color:#333;font:14px Arial") so images-off still looks intended.
  • Background images in classic Outlook need VML; if the text is readable on the fallback bgcolor, skip VML and accept the solid color.

Bulletproof buttons

Use an HTML button, not an image:

<table role="presentation" cellpadding="0" cellspacing="0" border="0">
  <tr>
    <td bgcolor="#1a5d3a" style="border-radius:6px;">
      <a href="https://example.com/pay"
         style="display:inline-block;padding:13px 24px;font:600 16px/1.2 Arial,sans-serif;
                color:#ffffff;text-decoration:none;border-radius:6px;">
        Pay invoice
      </a>
    </td>
  </tr>
</table>

Classic Outlook ignores padding on <a>, so the <td> bgcolor provides the visible area; for a fully clickable area in Outlook use a VML v:roundrect wrapped in <!--[if mso]> (MJML’s mj-button generates an acceptable version). One primary button per email; repeat it at most once at the bottom of long emails.

Preheader

The first text in <body> is the inbox preview. Add a hidden preheader of 40–90 characters that complements (does not repeat) the subject, then pad it with &zwnj;&nbsp; pairs so body text does not leak into the preview:

<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">
  Invoice #1042 for $480 is due April 3.&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;
</div>

Dark mode

Three client behaviors exist:

BehaviorClients (verify current state on caniemail.com)Strategy
Honors prefers-color-schemeApple Mail, iOS Mail, some Outlook (macOS/new)Write explicit dark styles in a media query
Partial inversion (light backgrounds darkened, dark left alone)Outlook.com, Outlook appsTarget [data-ogsc] / [data-ogsb] selectors for fixes
Full forced inversionGmail apps (notably iOS/Android)You cannot override; design to survive it

Rules that survive all three:

  • Add <meta name="color-scheme" content="light dark"> and <meta name="supported-color-schemes" content="light dark">, plus :root { color-scheme: light dark; }.
  • Never use pure #ffffff text on pure #000000; use off-black #111111-ish backgrounds and #f2f2f2 text in dark styles.
  • Logos: transparent PNG with a 2–3px light stroke or a pill background, or swap a dark variant via the media query. A black logo on transparent vanishes after inversion.
  • Avoid text baked into images; inversion cannot fix it.
  • Check brand color buttons after inversion: the client may darken the background but keep the white text — confirm contrast stays at least 4.5:1.

Accessibility checklist

  • <html lang="…">, <meta charset="utf-8">, meaningful <title>
  • role="presentation" on every layout table
  • Logical reading order matches visual order when stacked (source order = mobile order)
  • Text contrast at least 4.5:1, links distinguishable by more than color (underline in body text)
  • Link text names the destination (“View invoice #1042”, not “click here”)
  • Body 16px, tap targets at least 44px tall
  • A plain-text part exists and is readable on its own

Workflow

- [ ] 1. Define the one job of the email and the key fact/action above the fold
- [ ] 2. Choose toolchain (table above); scaffold from an existing template if present
- [ ] 3. Build mobile layout first in source order; widen to 600px
- [ ] 4. Inline-styled typography, images with width + alt, bulletproof button
- [ ] 5. Preheader, plain-text part, dark-mode styles, footer requirements
- [ ] 6. Compile, then lint the compiled HTML
- [ ] 7. Render-test across clients; fix; re-lint

Verification

  1. Lint the compiled HTML:

    python3 scripts/lint_email.py dist/emails/invoice.html           # exit 1 on errors
    python3 scripts/lint_email.py dist/emails/*.html --max-width 600

    It flags missing alt/lang/charset, external stylesheets, flex/grid layout divs, relative or data: image URLs, missing image widths, fixed widths over the limit, layout tables without role="presentation", unbalanced CSS braces, a missing hidden preheader, image-heavy/text-light emails, oversized <style>, and the ~102 KB Gmail clip. Fix every ERROR; justify any WARN you keep.

  2. Render-test in a preview service (Litmus, Email on Acid, or the ESP’s preview) on at minimum: classic Outlook 365 for Windows, new Outlook, Gmail web, Gmail iOS (dark), Apple Mail macOS and iOS (light and dark), Yahoo, Samsung Mail if your audience skews Android.

  3. Send real test emails to Gmail and Outlook.com accounts; confirm no “[Message clipped]” and correct preheader.

  4. Turn images off in a client and re-read: the message and action must still work.

  5. Check feature support for anything unusual on caniemail.com before relying on it.

Gotchas

  • Gmail rewrites class names and strips <style> in some contexts (for example non-Google accounts in the Gmail app); inline styles are the only guarantee.
  • margin: 0 auto centering fails in Outlook; use align="center" on the table cell.
  • Line-height on Outlook needs mso-line-height-rule: exactly to respect pixel values.
  • Outlook adds gaps under images unless they are display:block.
  • iOS auto-links dates, addresses and phone numbers in blue; wrap them or add <meta name="format-detection" content="telephone=no,date=no,address=no,email=no"> and style a[x-apple-data-detectors] to inherit color.
  • ESPs inject tracking and wrappers that add KB; measure the size of what the ESP sends, not just your build output.
  • Classic Outlook for Windows is being replaced by the web-based new Outlook, but enterprise audiences will run the Word engine for years — keep testing it.

Output when reporting

**Template:** invoice-ready (React Email 5 → dist/emails/invoice.html, 38 KB)
**Layout:** single column 600px fluid, stacks at 480px, 1 primary CTA
**Dark mode:** prefers-color-scheme styles + inversion-safe logo pill
**Lint:** 0 errors, 1 WARN kept (web font link — system fallback set)
**Client tests:** Outlook 365 Win, new Outlook, Gmail web/iOS dark, Apple Mail light/dark — pass

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/lint_email.py 233 lines · 10.9 KB
#!/usr/bin/env python3
"""Lint a compiled HTML email for rendering, accessibility and size problems.

Usage:
    python3 scripts/lint_email.py email.html [more.html ...] [--max-width 640]

Checks the *compiled* HTML (MJML / React Email / Maizzle output), not source
templates. Prints one line per finding and exits 1 if any ERROR was found.
Standard library only (Python 3.10+).
"""
from __future__ import annotations

import argparse
import re
import sys
from html.parser import HTMLParser

GMAIL_CLIP_BYTES = 102 * 1024          # Gmail clips the HTML body above ~102 KB
GMAIL_CLIP_WARN = 90 * 1024
GMAIL_STYLE_BYTES = 16 * 1024          # oversized <style> blocks are commonly dropped
VOID = {"img", "br", "hr", "meta", "link", "input", "source", "col", "area", "base", "wbr"}
BANNED_TAGS = {
    "script": "ERROR", "form": "WARN", "iframe": "ERROR", "video": "WARN",
    "object": "ERROR", "embed": "ERROR", "svg": "WARN", "input": "WARN", "button": "WARN",
}
HIDDEN_RE = re.compile(r"display\s*:\s*none|mso-hide\s*:\s*all|max-height\s*:\s*0|opacity\s*:\s*0", re.I)
LAYOUT_CSS_RE = re.compile(r"display\s*:\s*(flex|inline-flex|grid)|position\s*:\s*(absolute|fixed)", re.I)
WIDTH_PX_RE = re.compile(r"(?<![-\w])width\s*:\s*(\d+)px", re.I)


class EmailLinter(HTMLParser):
    def __init__(self, max_width: int) -> None:
        super().__init__(convert_charrefs=True)
        self.max_width = max_width
        self.findings: list[tuple[str, int, str]] = []
        self.stack: list[str] = []
        self.has_doctype = False
        self.html_lang = False
        self.meta_charset = False
        self.meta_viewport = False
        self.meta_color_scheme = False
        self.in_style = False
        self.style_bytes = 0
        self.style_text: list[str] = []
        self.body_seen = False
        self.first_body_text_hidden: bool | None = None
        self.hidden_depth: int | None = None
        self.text_chars = 0
        self.img_count = 0
        self.table_count = 0
        self.presentation_tables = 0
        self.table_stack: list[dict] = []
        self.in_title = False
        self.title_text = ""

    def add(self, level: str, msg: str) -> None:
        self.findings.append((level, self.getpos()[0], msg))

    def handle_decl(self, decl: str) -> None:
        if decl.lower().startswith("doctype"):
            self.has_doctype = True

    def handle_starttag(self, tag: str, attrs_list) -> None:
        attrs = {k.lower(): (v or "") for k, v in attrs_list}
        style = attrs.get("style", "")
        if tag not in VOID:
            self.stack.append(tag)
            if self.hidden_depth is None and HIDDEN_RE.search(style) and self.body_seen:
                self.hidden_depth = len(self.stack)

        if tag == "html":
            self.html_lang = bool(attrs.get("lang"))
        elif tag == "meta":
            if "charset" in attrs or attrs.get("http-equiv", "").lower() == "content-type":
                self.meta_charset = True
            name = attrs.get("name", "").lower()
            if name == "viewport":
                self.meta_viewport = True
            if name in ("color-scheme", "supported-color-schemes"):
                self.meta_color_scheme = True
        elif tag == "title":
            self.in_title = True
        elif tag == "link" and "stylesheet" in attrs.get("rel", "").lower():
            href = attrs.get("href", "")
            if "fonts.googleapis.com" in href or "fonts.bunny.net" in href:
                self.add("INFO", "web font <link>: only some clients load it; keep a system fallback stack")
            else:
                self.add("ERROR", f"external stylesheet <link href='{href}'>: most clients strip it; inline the CSS")
        elif tag == "style":
            self.in_style = True
        elif tag == "body":
            self.body_seen = True
        elif tag == "img":
            self.img_count += 1
            src = attrs.get("src", "")
            if "alt" not in attrs:
                self.add("ERROR", f"<img src='{src[:60]}'> has no alt attribute (use alt=\"\" for decorative images)")
            if src and not src.startswith(("https://", "cid:", "data:")):
                level = "ERROR" if not src.startswith("http://") else "WARN"
                self.add(level, f"<img src='{src[:60]}'> is not an absolute https URL")
            if src.startswith("data:"):
                self.add("WARN", "data: URI image: blocked by Gmail and Outlook, and inflates size toward clipping")
            if "width" not in attrs:
                self.add("WARN", f"<img src='{src[:60]}'> lacks a width attribute; classic Outlook renders it at native size")
            elif attrs["width"].isdigit() and int(attrs["width"]) > self.max_width:
                self.add("WARN", f"<img> width={attrs['width']} exceeds {self.max_width}px")
        elif tag == "table":
            self.table_count += 1
            role = attrs.get("role", "").lower()
            if role == "presentation":
                self.presentation_tables += 1
            self.table_stack.append({"role": role, "has_th": False, "line": self.getpos()[0]})
            w = attrs.get("width", "")
            if w.isdigit() and int(w) > self.max_width:
                self.add("WARN", f"<table width={w}> exceeds {self.max_width}px; stays wide on mobile and in preview panes")
        elif tag == "th" and self.table_stack:
            self.table_stack[-1]["has_th"] = True
        elif tag == "a":
            href = attrs.get("href", "").strip()
            if not href or href == "#":
                self.add("WARN", "<a> with empty or '#' href")
            elif href.lower().startswith("javascript:"):
                self.add("ERROR", "<a href='javascript:...'> is stripped by every client")
        elif tag in BANNED_TAGS:
            self.add(BANNED_TAGS[tag], f"<{tag}> is unsupported or stripped in major email clients")

        if tag == "div" and LAYOUT_CSS_RE.search(style):
            self.add("ERROR", f"<div style='{style[:50]}'> uses flex/grid/absolute positioning; classic Outlook ignores it — use tables")
        elif LAYOUT_CSS_RE.search(style):
            self.add("WARN", f"<{tag}> uses flex/grid/absolute positioning; check fallback in Outlook and Gmail")
        for m in WIDTH_PX_RE.finditer(style):
            if int(m.group(1)) > self.max_width and "max-width" not in style[: m.start()].lower()[-12:]:
                self.add("WARN", f"<{tag}> inline width {m.group(1)}px exceeds {self.max_width}px")

    def handle_endtag(self, tag: str) -> None:
        if tag == "style":
            self.in_style = False
        if tag == "title":
            self.in_title = False
        if tag == "table" and self.table_stack:
            t = self.table_stack.pop()
            if t["role"] != "presentation" and not t["has_th"]:
                self.findings.append(("WARN", t["line"], "layout <table> without role=\"presentation\"; screen readers announce rows and columns"))
        if tag in self.stack:
            while self.stack:
                popped = self.stack.pop()
                if self.hidden_depth is not None and len(self.stack) < self.hidden_depth:
                    self.hidden_depth = None
                if popped == tag:
                    break

    def handle_data(self, data: str) -> None:
        if self.in_style:
            self.style_bytes += len(data.encode())
            self.style_text.append(data)
            return
        if self.in_title:
            self.title_text += data
        if not self.body_seen:
            return
        text = data.strip().replace("‌", "").replace(" ", "").replace("͏", "")
        if text:
            if self.first_body_text_hidden is None:
                self.first_body_text_hidden = self.hidden_depth is not None
            self.text_chars += len(text)


def lint(path: str, max_width: int) -> int:
    with open(path, "rb") as fh:
        raw = fh.read()
    size = len(raw)
    html = raw.decode("utf-8", errors="replace")
    p = EmailLinter(max_width)
    p.feed(html)
    p.close()

    if size > GMAIL_CLIP_BYTES:
        p.findings.append(("ERROR", 0, f"HTML is {size/1024:.1f} KB; Gmail clips above ~102 KB (hides footer and unsubscribe)"))
    elif size > GMAIL_CLIP_WARN:
        p.findings.append(("WARN", 0, f"HTML is {size/1024:.1f} KB; close to Gmail's ~102 KB clipping threshold"))
    if not p.has_doctype:
        p.findings.append(("WARN", 0, "missing <!DOCTYPE html>; some clients fall into quirks mode"))
    if not p.html_lang:
        p.findings.append(("ERROR", 0, "<html> has no lang attribute; screen readers guess the language"))
    if not p.meta_charset:
        p.findings.append(("WARN", 0, "no <meta charset>; non-ASCII text may garble"))
    if not p.meta_viewport:
        p.findings.append(("WARN", 0, "no <meta name=viewport>; mobile clients may zoom out"))
    if not p.meta_color_scheme:
        p.findings.append(("INFO", 0, "no color-scheme meta; Apple Mail will not apply your dark-mode styles"))
    if not p.title_text.strip():
        p.findings.append(("WARN", 0, "empty <title>; shown by some clients and web views"))
    if p.style_bytes > GMAIL_STYLE_BYTES:
        p.findings.append(("WARN", 0, f"<style> totals {p.style_bytes/1024:.1f} KB; Gmail may drop oversized style blocks"))
    css = "".join(p.style_text)
    if css and css.count("{") != css.count("}"):
        p.findings.append(("ERROR", 0, "unbalanced braces in <style>; Gmail discards the entire block on a syntax error"))
    if p.first_body_text_hidden is not True:
        p.findings.append(("WARN", 0, "no hidden preheader as first body text; inbox preview will show the first visible text"))
    if p.img_count and p.text_chars < 200:
        p.findings.append(("WARN", 0, f"only {p.text_chars} chars of live text with {p.img_count} images; image-only emails fail with images off and hurt deliverability"))
    if p.table_count == 0:
        p.findings.append(("WARN", 0, "no tables found; div-only layouts break in classic Outlook for Windows"))

    order = {"ERROR": 0, "WARN": 1, "INFO": 2}
    p.findings.sort(key=lambda f: (order[f[0]], f[1]))
    print(f"== {path} ({size/1024:.1f} KB, {p.table_count} tables, {p.img_count} images)")
    for level, line, msg in p.findings:
        loc = f"L{line}" if line else "doc"
        print(f"  {level:<5} {loc:<6} {msg}")
    errors = sum(1 for f in p.findings if f[0] == "ERROR")
    warns = sum(1 for f in p.findings if f[0] == "WARN")
    print(f"  -> {errors} error(s), {warns} warning(s)")
    return errors


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("files", nargs="+", help="compiled .html email files")
    ap.add_argument("--max-width", type=int, default=640, help="widest allowed fixed width in px (default 640)")
    args = ap.parse_args()
    total = 0
    for f in args.files:
        try:
            total += lint(f, args.max_width)
        except OSError as exc:
            print(f"ERROR cannot read {f}: {exc}", file=sys.stderr)
            total += 1
    return 1 if total else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc