Back to skills
SkillHub ClubWrite Technical DocsFull StackTech Writer

message

Create and edit rich text message drafts for Gmail, Outlook, and WhatsApp. Writes Markdown fragments and assembles platform-specific HTML via build script. Use when writing emails, drafting emails, composing replies, sending messages, writing WhatsApp messages, sending Gmail messages, replying via email, or when user mentions Gmail, Outlook, WhatsApp, email client, "email to", "reply to", "draft an email", "write an email", "send a message", "message to", "WhatsApp to", or professional correspondence.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
37
Hot score
90
Updated
March 20, 2026
Overall rating
C2.1
Composite score
2.1
Best-practice grade
B71.9

Install command

npx @skill-hub/cli install henkisdabro-wookstar-claude-code-plugins-message

Repository

henkisdabro/wookstar-claude-code-plugins

Skill path: plugins/message/skills/message

Create and edit rich text message drafts for Gmail, Outlook, and WhatsApp. Writes Markdown fragments and assembles platform-specific HTML via build script. Use when writing emails, drafting emails, composing replies, sending messages, writing WhatsApp messages, sending Gmail messages, replying via email, or when user mentions Gmail, Outlook, WhatsApp, email client, "email to", "reply to", "draft an email", "write an email", "send a message", "message to", "WhatsApp to", or professional correspondence.

Open repository

Best for

Primary workflow: Write Technical Docs.

Technical facets: Full Stack, Tech Writer.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: henkisdabro.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install message into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/henkisdabro/wookstar-claude-code-plugins before adding message to shared team environments
  • Use message for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: message
description: Create and edit rich text message drafts for Gmail, Outlook, and WhatsApp. Writes Markdown fragments and assembles platform-specific HTML via build script. Use when writing emails, drafting emails, composing replies, sending messages, writing WhatsApp messages, sending Gmail messages, replying via email, or when user mentions Gmail, Outlook, WhatsApp, email client, "email to", "reply to", "draft an email", "write an email", "send a message", "message to", "WhatsApp to", or professional correspondence.
argument-hint: "[optional: path to existing .fragment.md for editing]"
allowed-tools: Bash, Write, Read, Edit
---

# Message Drafts

Create rich text messages that paste perfectly into Gmail, Outlook, or convert to WhatsApp-formatted text. Fragments are written in Markdown - the build script converts to platform-specific HTML automatically.

## Architecture

```
Claude writes              Build script assembles          Output (separate file)
name.fragment.md  --->     Markdown -> HTML                name.html
(10-30 lines)              Gmail transform (tags)          (self-contained preview
                           Outlook transform (styles)       with three body versions)
                           Inject into shell.html
```

The fragment is the source of truth. The assembled HTML is a derived output. Never edit the `.html` output directly.

## Create Workflow

1. Draft email content in conversation
2. Write a `.fragment.md` file to `data/writing/email_drafts/`
3. Run the assembler with `--serve` to produce the preview HTML and launch the server

```bash
uv run .claude/skills/message/scripts/assemble.py /path/to/name.fragment.md --serve
```

Run with `run_in_background: true`. The server picks a random free port and prints the URL. The preview auto-refreshes when the HTML file changes - no manual browser refresh needed.

## Edit Workflow

When editing an existing email (argument provided, or user asks to change something):

1. Read the small `.fragment.md` file
2. Use the Edit tool to make targeted changes
3. Re-run the assembler **without** `--serve` to rebuild the preview

```bash
uv run .claude/skills/message/scripts/assemble.py /path/to/name.fragment.md
```

The preview server is still running in the background from the create step. The assembler overwrites the HTML file atomically, and the browser auto-refreshes within 2 seconds. Do **not** re-launch the server on edits - the user keeps the same URL.

## Fragment Format

Fragments are Markdown files with YAML frontmatter:

```markdown
---
to: [email protected]
cc: [email protected]
subject: Email Subject
---

Hi **Stuart**,

Here's the ~~old approach~~ new approach.

## Key Points

- First item
- Second item

> Quoted text from previous email

| Feature | Status |
|---------|--------|
| Auth    | Done   |

Inline `code` and [a link](https://example.com).

Cheers,
Henrik
```

### Frontmatter Fields

| Field | Required | Notes |
|-------|----------|-------|
| `to` | Yes | Comma-separated recipients |
| `subject` | Yes | Email subject line |
| `cc` | No | Hidden in preview if empty |
| `bcc` | No | Hidden in preview if empty |

### Formatting Reference

Standard Markdown maps to platform-specific HTML automatically:

| What you want | Write in Markdown |
|---------------|-------------------|
| Bold | `**bold**` |
| Italic | `*italic*` |
| Strikethrough | `~~strikethrough~~` |
| Heading | `## Heading` |
| Bullet list | `- item` |
| Numbered list | `1. item` |
| Blockquote | `> quoted text` |
| Inline code | `` `code` `` |
| Code block | triple backticks |
| Link | `[text](url)` |
| Table | `| col | col |` with separator row |

For features not in standard Markdown, embed HTML directly (it passes through unchanged):

| What you want | Embed as HTML |
|---------------|---------------|
| Custom colour | `<span style="color: #c0392b;">red text</span>` |
| Large text | `<font size="4">larger text</font>` |
| Underline | `<u>underlined</u>` |
| Indent | `<blockquote style="margin: 0 0 0 40px; border: none; padding: 0px;">indented</blockquote>` |

### Example: User Asks for Colour and Strikethrough

```
User: "Write an email to Stuart. Strikethrough '$5000' and make his name red."

Claude writes .fragment.md:

---
to: [email protected]
subject: Pricing Update
---

Hi <span style="color: #c0392b;">Stuart</span>,

The old pricing was ~~$5000~~ but the new rate is $3000.

Cheers,
Henrik
```

## File Naming

```
data/writing/email_drafts/YYYY-MM-DD_recipient_subject.fragment.md   <- source
data/writing/email_drafts/YYYY-MM-DD_recipient_subject.html          <- assembled output
```

Examples:

- `2026-02-12_veronika_audit-proposal.fragment.md`
- `2026-02-12_danielle_project-update.fragment.md`

## Platform Transforms

The build script produces three HTML versions from a single Markdown source:

**Gmail** (tag transform): Converts semantic HTML to Gmail-native elements. `<p>` becomes `<div>`, `<strong>` becomes `<b>`, `<em>` becomes `<i>`, headings become `<font size>` with `<b>`. Container gets Arial 13px. No custom colours added - only platform defaults.

**Outlook** (style injection): Adds inline styles to every element for Word's rendering engine. Aptos/Calibri 11pt, explicit `color: #000000` on every text element, `mso-line-height-rule` for spacing control. No custom colours added - only platform defaults.

**WhatsApp** (text conversion): Strips HTML and converts to WhatsApp markdown - `*bold*`, `_italic_`, `~strikethrough~`. Tables become pipe-separated text.

User-specified inline styles (colours, font sizes) are preserved through all transforms.

## Preview

The assembled HTML has a **Gmail/Outlook/WhatsApp mode toggle**:

- **Gmail mode**: Gmail-native HTML with Arial 13px container, Gmail action buttons (Compose in Gmail, Open Gmail Inbox, Copy for Gmail)
- **Outlook mode**: Outlook-native HTML with full inline styles, Outlook action button (Copy for Outlook)
- **WhatsApp mode**: Converts to WhatsApp-compatible plain text. Action buttons: Copy for WhatsApp, Send via WhatsApp

Instruct user:

1. Click the URL printed by the server
2. Select Gmail, Outlook, or WhatsApp mode
3. Review the email preview
4. Use the action buttons for their chosen client

## Backward Compatibility

Legacy `.fragment.html` files still work. The assembler detects the extension and uses the appropriate parser. For HTML fragments, the same body is used for all three platform views (matching previous behaviour).

## Structure

Follow this order in every email:

1. **Lead with the point** - the request, update, instruction, or information the recipient needs to act on. This is why they opened the email.
2. **Supporting detail** - context, logistics, or reference material that supports the lead.
3. **Warm close** - any personal, relational, or appreciative content goes at the end, just before the sign-off. Gratitude, compliments, looking-forward sentiments, and rapport-building belong here - never at the top.

The recipient should know what the email is about within the first two lines. Warmth is the exit feeling, not the entrance.

## Tone

- Write naturally, as a human would compose
- Avoid repetitive language from previous emails in the thread
- Keep prose flowing, not overly structured
- Match the recipient's formality level
- Use British English spelling (colour, analyse, organise, behaviour, centre)

## Henrik's Default Details

Use these when composing emails on Henrik's behalf:

| Purpose | Email |
|---------|-------|
| Primary / From address | [email protected] |
| Google platform access requests (GA4, GTM, Google Ads, Search Console, etc.) | [email protected] |

Sign-off block:

```
Henrik Soederlund
Independent Digital Consultant
[email protected]
```

## References

- `references/outlook-formatting.md` - Outlook element styles and colour palette reference
- `references/formatting-rules.md` - Gmail native HTML element reference

## After Preview

Once the user has the preview URL, ask whether they'd like to run the **humaniser** skill on the message to remove any AI writing patterns before sending. Example prompt: "Would you like me to run the humaniser skill on this draft to make it sound more natural?"

## Post-Draft Actions

After saving a draft, prompt Henrik: "Log this to the client comms log?" If yes, write the entry to `data/consulting/clients/[slug]/comms/_comms-log.json` via inline Python or the Edit tool (`manage_comms.py` is read-only - no --add flag).


---

## Referenced Files

> The following files are referenced in this skill and included for context.

### references/outlook-formatting.md

```markdown
# Outlook Email Formatting - Copy-Paste Reference

> **Note:** Fragments are now Markdown. This file documents Outlook's inline style requirements for reference - the build script applies these styles automatically. For the fragment format, see SKILL.md.

Consolidated snippets for Outlook-compatible HTML emails with 100% inline styles.

## Document Structure

Every email starts with MSO conditional comments:

```html
<!DOCTYPE html>
<html xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
</head>
<body>
<!-- Content here -->
</body>
</html>
```

Note: Do not add styles to the `<body>` tag - they are ignored when copy/pasting. Style each element individually.

## Paragraphs

### Body Text

```html
<p style="font-family: Aptos, Calibri, Arial, sans-serif; font-size: 11pt; color: #000000; margin: 0 0 12pt 0;">Text content here.</p>
```

### Blank Line (Spacing)

```html
<p style="font-family: Aptos, Calibri, Arial, sans-serif; font-size: 11pt; color: #000000; margin: 0;">&nbsp;</p>
```

**Spacing rules:**

- Double blank lines (`&nbsp;` x2) before major section headings
- Single blank line before and after tables
- Single blank line after bullet/numbered lists

## Headings

### Main Heading (14pt)

```html
<p style="font-family: Aptos, Calibri, Arial, sans-serif; font-size: 14pt; font-weight: bold; color: #1a5276; margin: 0 0 12pt 0;">Heading Text</p>
```

### Sub-heading (12pt)

```html
<p style="font-family: Aptos, Calibri, Arial, sans-serif; font-size: 12pt; font-weight: bold; color: #1a5276; margin: 0 0 12pt 0;">Sub-heading Text</p>
```

### Inline Bold Label

```html
<p style="font-family: Aptos, Calibri, Arial, sans-serif; font-size: 11pt; color: #000000; margin: 0 0 6pt 0;"><strong>Label:</strong> Description text</p>
```

## Tables

### Table Structure

```html
<table border="1" cellpadding="8" cellspacing="0" style="border-collapse: collapse; border: 1px solid #999999; font-family: Aptos, Calibri, Arial, sans-serif; font-size: 11pt; margin: 12pt 0;">
```

### Header Row

```html
<tr style="background-color: #f2f2f2;">
<th style="text-align: left; border: 1px solid #999999; color: #000000; padding: 8px;">Text Header</th>
<th style="text-align: right; border: 1px solid #999999; color: #000000; padding: 8px;">Numeric Header</th>
</tr>
```

### Normal Row

```html
<tr>
<td style="border: 1px solid #999999; color: #000000; padding: 8px;">Text value</td>
<td style="text-align: right; border: 1px solid #999999; color: #000000; padding: 8px;">1,234</td>
</tr>
```

**Column alignment:**

- Left-align: Text, labels, descriptions
- Right-align: Numbers, percentages, currency

## Row Highlighting

### Success/Positive (Green)

```html
<tr style="background-color: #e8f6e8;">
<td style="border: 1px solid #999999; color: #000000; padding: 8px;">Label</td>
<td style="text-align: right; border: 1px solid #999999; color: #27ae60; padding: 8px;"><strong>+12.3%</strong></td>
</tr>
```

### Warning/Negative (Red)

```html
<tr style="background-color: #ffeaea;">
<td style="border: 1px solid #999999; color: #000000; padding: 8px;">Label</td>
<td style="text-align: right; border: 1px solid #999999; color: #c0392b; padding: 8px;"><strong>-5.4%</strong></td>
</tr>
```

### Attention/Important (Yellow)

```html
<tr style="background-color: #fff9e6;">
<td style="border: 1px solid #999999; color: #000000; padding: 8px;"><strong>Important</strong></td>
<td style="text-align: right; border: 1px solid #999999; color: #000000; padding: 8px;"><strong>Value</strong></td>
</tr>
```

### Informational (Blue)

```html
<tr style="background-color: #e8f0fe;">
<td style="border: 1px solid #999999; color: #000000; padding: 8px;">Info row</td>
<td style="text-align: right; border: 1px solid #999999; color: #2980b9; padding: 8px;"><strong>Value</strong></td>
</tr>
```

## Lists

### Bullet List

```html
<ul style="font-family: Aptos, Calibri, Arial, sans-serif; font-size: 11pt; color: #000000; margin: 0 0 12pt 0;">
<li style="color: #000000; margin-bottom: 6pt;"><strong>Label:</strong> Description</li>
<li style="color: #000000; margin-bottom: 6pt;"><strong>Label:</strong> Description</li>
<li style="color: #000000; margin-bottom: 6pt;"><strong>Label:</strong> Description</li>
</ul>
```

### Numbered List

```html
<ol style="font-family: Aptos, Calibri, Arial, sans-serif; font-size: 11pt; color: #000000; margin: 0 0 12pt 0;">
<li style="color: #000000; margin-bottom: 6pt;">First item</li>
<li style="color: #000000; margin-bottom: 6pt;">Second item</li>
<li style="color: #000000; margin-bottom: 6pt;">Third item</li>
</ol>
```

## Other Elements

### Horizontal Rule

```html
<hr style="border: none; border-top: 1px solid #cccccc; margin: 24pt 0;">
```

### Coloured Warning Text

```html
<p style="font-family: Aptos, Calibri, Arial, sans-serif; font-size: 11pt; color: #c0392b; font-weight: bold; margin: 0 0 6pt 0;">Warning heading</p>
```

### Key Insight Callout

```html
<p style="font-family: Aptos, Calibri, Arial, sans-serif; font-size: 11pt; color: #000000; margin: 0 0 12pt 0;"><strong>Key insight:</strong> Interpretation text here.</p>
```

### Email Signature

```html
<p style="font-family: Aptos, Calibri, Arial, sans-serif; font-size: 11pt; color: #000000; margin: 0 0 12pt 0;">Cheers,<br>Henrik</p>
```

## Colour Reference

| Purpose | Colour Code |
|---------|-------------|
| Heading text | #1a5276 |
| Body text | #000000 |
| Success text | #27ae60 |
| Success background | #e8f6e8 |
| Warning text | #c0392b |
| Warning background | #ffeaea |
| Yellow highlight bg | #fff9e6 |
| Blue highlight bg | #e8f0fe |
| Blue highlight text | #2980b9 |
| Table header bg | #f2f2f2 |
| Border colour | #999999 |
| HR colour | #cccccc |

```

### references/formatting-rules.md

```markdown
# Gmail Email Formatting Rules - Complete Reference

> **Note:** Fragments are now Markdown. This file documents Gmail's native HTML elements for reference - the build script uses these transforms automatically. For the fragment format, see SKILL.md. For Outlook element styles, see outlook-formatting.md.

## CSS Styling (Gmail Exact Match)

```css
body {
  font-family: Arial, Helvetica, sans-serif;
  font-size: 13px;
  line-height: 19.5px;
  color: rgb(34, 34, 34);
  max-width: 568px;
}
```

These values are extracted directly from Gmail's compose window and ensure the email looks identical when pasted.

## Spacing Rules

### Between Paragraphs

- Use `<br><br>` between paragraphs (one blank line)
- No CSS margins on paragraphs

### Before Bold Headings/Sections

- Use `<br><br>` before `<b>` headings (one blank line)

### After Bold Headings

- NO blank line after `<b>Heading</b>`
- Text follows immediately on next line with single `<br>`

**Correct:**

```html
[Previous paragraph text]
<br><br>
<b>Project Title - $2,500</b>
<br>Description starts here on the next line...
```

**Wrong:**

```html
<b>Project Title - $2,500</b>
<br><br>Description with extra blank line after heading...
```

### After Lists

- Single `<br>` before continuing prose
- No extra spacing needed after `</ul>` or `</ol>`

## Structure Rules

### Avoid Auto-Generated Appearance

- NO horizontal rules (`<hr>`)
- NO numbered headings (1. 2. 3.)
- NO `<h1>`, `<h2>`, etc. tags - use `<b>` for emphasis
- NO excessive structure - let prose flow naturally

### When to Use Bullet Lists

Use bullet lists ONLY when genuinely listing:

- Feature sets
- Multiple distinct options
- Requirements or deliverables

Do NOT use bullets for:

- General information that reads as prose
- Single items
- Sequential steps (use prose instead)

## Lists, Quotes, and Formatting (Critical for Gmail Paste)

Gmail's paste handler requires proper HTML elements. Using `&bull;` entities or manual `1. 2. 3.` numbering with `<br>` tags will paste as **plain text**, not formatted lists.

**Important:** Inline styles on list elements (`<ul>`, `<ol>`, `<li>`) are for **preview rendering only**. Gmail strips them on paste and applies its own defaults. The `<style>` block is also stripped. Always use proper HTML tags - that's what matters for paste.

### Bullet Lists

Use `<ul><li>` (inline styles for preview only):

```html
<ul style="margin: 8px 0; padding-left: 28px;">
<li style="margin-bottom: 2px;">First item</li>
<li style="margin-bottom: 2px;">Second item</li>
<li>Last item (no margin-bottom on last)</li>
</ul>
```

Gmail internally renders this as bare `<ul><li>item</li></ul>` with no styles.

### Numbered Lists

Use `<ol><li>` (inline styles for preview only):

```html
<ol style="margin: 8px 0; padding-left: 28px;">
<li style="margin-bottom: 2px;">First step</li>
<li style="margin-bottom: 2px;">Second step</li>
<li>Final step</li>
</ol>
```

### Quote Blocks

Use `<blockquote>` with Gmail's exact `gmail_quote` class and styling:

```html
<blockquote class="gmail_quote" style="margin: 0px 0px 0px 0.8ex; border-left: 1px solid rgb(204, 204, 204); padding-left: 1ex;">
Quoted text here.
</blockquote>
```

This is Gmail's **native** quote format - extracted directly from the compose window.

### Indented Text (no border)

Different from quotes - uses `<blockquote>` with left margin only:

```html
<blockquote style="margin: 0 0 0 40px; border: none; padding: 0px;">
Indented text here.
</blockquote>
```

### Strikethrough

```html
<strike>Struck-through text</strike>
```

### Links

Use `<a>` with Gmail's default blue:

```html
<a href="https://example.com" style="color: rgb(17, 85, 204);">Link text</a>
```

### Font Sizes (rarely needed in professional emails)

Gmail uses the HTML `<font size>` attribute:

```html
<font size="1">Small</font>     <!-- ~10px -->
<font size="4">Large</font>     <!-- ~18px -->
<font size="6">Huge</font>      <!-- ~32px -->
```

Default body text has no size attribute (renders at 13px with our CSS).

### Bold, Italic, Underline

```html
<b>Bold text</b>
<i>Italic text</i>
<u>Underlined text</u>
```

### What NOT to Use

| Wrong | Right |
|-------|-------|
| `&bull; Item<br>` | `<ul><li>Item</li></ul>` |
| `1. Step one<br>` | `<ol><li>Step one</li></ol>` |
| `> Quote text` | `<blockquote class="gmail_quote" ...>text</blockquote>` |
| Plain URL text | `<a href="..." style="color: rgb(17, 85, 204);">text</a>` |
| `<del>text</del>` | `<strike>text</strike>` |

## Gmail Native Paragraph Structure

Gmail internally wraps each line in `<div>` tags and uses `<div><br></div>` for blank lines. Our templates use `<br><br>` between paragraphs which works fine for pasting - Gmail converts them automatically. Both approaches produce identical results in the compose window.

## Tone Guidelines

### Natural Writing

- Vary sentence structure
- Use contractions where appropriate
- Match the recipient's tone
- Avoid corporate jargon

### Avoid Repetition

When replying to an email thread:

- Read the previous emails
- Do NOT reuse the same phrases
- Find fresh ways to express similar sentiments

## HTML Template Structure

```html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    body {
      font-family: Arial, Helvetica, sans-serif;
      font-size: 13px;
      line-height: 19.5px;
      color: rgb(34, 34, 34);
      max-width: 568px;
    }
    ul, ol { margin: 8px 0; padding-left: 28px; }
    li { margin-bottom: 2px; }
    blockquote.gmail_quote { margin: 0px 0px 0px 0.8ex; border-left: 1px solid rgb(204, 204, 204); padding-left: 1ex; }
    a { color: rgb(17, 85, 204); }
  </style>
</head>
<body>

[EMAIL CONTENT HERE]

</body>
</html>
```

**Important:** Always use inline styles on `<ul>`, `<ol>`, `<li>`, `<blockquote>`, and `<a>` tags in the email body. The `<style>` block is for the preview only - Gmail strips `<style>` tags during paste.

## Local Preview

Assemble and launch the preview server:

```bash
python3 ${CLAUDE_PLUGIN_ROOT}/skills/message/scripts/assemble.py /path/to/name.fragment.html --serve
```

Run with `run_in_background: true` in Claude Code. Auto-stops after 10 minutes idle.

User workflow:

1. Click the URL printed by the server
2. Select Gmail, Outlook, or WhatsApp mode
3. Review the email preview (To, CC, BCC, Subject, Body)
4. Use platform-specific action buttons to send

## File Organisation

### Naming Convention

```
YYYY-MM-DD_recipient_subject.fragment.html   (source - Claude writes/edits)
YYYY-MM-DD_recipient_subject.html            (output - build script writes)
```

### Save Location

```
data/writing/email_drafts/
```

```



---

## Skill Companion Files

> Additional files collected from the skill directory layout.

### scripts/assemble.py

```python
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.9"
# dependencies = ["markdown"]
# ///
"""Assemble a fragment file into a full email preview.

Reads a .fragment.md (Markdown with YAML frontmatter) or .fragment.html
(legacy format), converts to platform-specific HTML for Gmail and Outlook,
and injects all three versions into the shell.html template.

Usage:
    uv run assemble.py /path/to/name.fragment.md [--serve] [--timeout 600]
"""

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


# ---------------------------------------------------------------------------
# Fragment parsing
# ---------------------------------------------------------------------------

class FragmentMetaParser(HTMLParser):
    """Extract <meta name="..." content="..."> tags from fragment HTML."""

    def __init__(self):
        super().__init__()
        self.meta = {}

    def handle_starttag(self, tag, attrs):
        if tag == "meta":
            attr_dict = dict(attrs)
            name = attr_dict.get("name", "")
            content = attr_dict.get("content", "")
            if name:
                self.meta[name] = content


def parse_fragment_html(fragment_path):
    """Parse a legacy .fragment.html file, returning meta dict and body HTML."""
    with open(fragment_path, encoding="utf-8") as f:
        content = f.read()

    parser = FragmentMetaParser()
    parser.feed(content)
    meta = parser.meta

    body_match = re.search(
        r"<body[^>]*>(.*)</body>", content, re.DOTALL | re.IGNORECASE
    )
    if not body_match:
        print("Error: No <body> tags found in fragment", file=sys.stderr)
        sys.exit(1)

    body = body_match.group(1).strip()
    return meta, body


def parse_fragment_md(fragment_path):
    """Parse a .fragment.md file with YAML frontmatter, returning meta dict and HTML body."""
    try:
        import markdown
    except ImportError:
        print(
            "Error: 'markdown' package required. Install with: pip install markdown",
            file=sys.stderr,
        )
        sys.exit(1)

    with open(fragment_path, encoding="utf-8") as f:
        content = f.read()

    # Split frontmatter from body
    meta = {}
    body_text = content
    if content.startswith("---"):
        parts = content.split("---", 2)
        if len(parts) >= 3:
            frontmatter = parts[1].strip()
            body_text = parts[2].strip()
            for line in frontmatter.splitlines():
                line = line.strip()
                if ":" in line:
                    key, _, value = line.partition(":")
                    meta[key.strip()] = value.strip()

    # Pre-process: convert ~~text~~ to <del>text</del> (not in standard markdown lib)
    body_text = re.sub(r"~~(.+?)~~", r"<del>\1</del>", body_text)

    # Convert Markdown to HTML
    md = markdown.Markdown(extensions=["extra"])
    html_body = md.convert(body_text)

    return meta, html_body


def validate_fragment(meta, body):
    """Validate required fields are present."""
    errors = []
    if not meta.get("to"):
        errors.append("Missing required field: to")
    if not meta.get("subject"):
        errors.append("Missing required field: subject")
    if not body:
        errors.append("Empty body content")

    if errors:
        for err in errors:
            print(f"Error: {err}", file=sys.stderr)
        sys.exit(1)


# ---------------------------------------------------------------------------
# Gmail transform: convert semantic HTML to Gmail-native elements
# ---------------------------------------------------------------------------

def transform_gmail(html):
    """Convert clean HTML to Gmail-native elements.

    Gmail uses its own element set: <div> not <p>, <b> not <strong>,
    <i> not <em>, <font> for sizing, bare lists with no inline styles.
    """
    result = html

    # Headings: h1 -> <div><font size="6"><b>text</b></font></div>
    result = re.sub(
        r"<h1[^>]*>(.*?)</h1>",
        r'<div><font size="6"><b>\1</b></font></div>',
        result,
        flags=re.DOTALL,
    )
    result = re.sub(
        r"<h2[^>]*>(.*?)</h2>",
        r'<div><font size="4"><b>\1</b></font></div>',
        result,
        flags=re.DOTALL,
    )
    result = re.sub(
        r"<h3[^>]*>(.*?)</h3>",
        r"<div><b>\1</b></div>",
        result,
        flags=re.DOTALL,
    )
    result = re.sub(
        r"<h[4-6][^>]*>(.*?)</h[4-6]>",
        r"<div><b>\1</b></div>",
        result,
        flags=re.DOTALL,
    )

    # Blockquote: add Gmail quote styling
    result = re.sub(
        r"<blockquote(?:\s[^>]*)?>",
        '<blockquote class="gmail_quote" style="margin: 0px 0px 0px 0.8ex; border-left: 1px solid rgb(204, 204, 204); padding-left: 1ex;">',
        result,
    )

    # Pre+code blocks: <pre><code>text</code></pre> -> <div><font face="monospace">text</font></div>
    result = re.sub(
        r"<pre[^>]*>\s*<code[^>]*>(.*?)</code>\s*</pre>",
        r'<div><font face="monospace">\1</font></div>',
        result,
        flags=re.DOTALL,
    )

    # Inline code: <code>text</code> -> <font face="monospace">text</font>
    result = re.sub(
        r"<code[^>]*>(.*?)</code>",
        r'<font face="monospace">\1</font>',
        result,
        flags=re.DOTALL,
    )

    # <strong> -> <b>
    result = re.sub(r"<strong([^>]*)>", r"<b\1>", result)
    result = result.replace("</strong>", "</b>")

    # <em> -> <i>
    result = re.sub(r"<em([^>]*)>", r"<i\1>", result)
    result = result.replace("</em>", "</i>")

    # <del> -> <strike>
    result = re.sub(r"<del([^>]*)>", r"<strike\1>", result)
    result = result.replace("</del>", "</strike>")

    # Links: add Gmail default blue
    result = re.sub(
        r'<a\s+(href="[^"]*")',
        r'<a \1 style="color: rgb(17, 85, 204);"',
        result,
    )
    # Fix links that already have a style attribute (from user HTML)
    # - don't double-add style on <a> tags that already have style=
    # The regex above only matches <a href="..."> without existing style.
    # Links with existing style (user-specified colours) are preserved as-is.

    # Tables: add basic border styles for paste compatibility
    result = re.sub(
        r"<table(?:\s[^>]*)?>",
        '<table border="1" cellpadding="8" cellspacing="0" style="border-collapse: collapse; border: 1px solid #999999;">',
        result,
    )
    result = re.sub(
        r"<td(?:\s[^>]*)?>",
        '<td style="border: 1px solid #999999; padding: 8px;">',
        result,
    )
    result = re.sub(
        r"<th(?:\s[^>]*)?>",
        '<th style="border: 1px solid #999999; padding: 8px; font-weight: bold;">',
        result,
    )

    # <p> -> <div> (must be last to avoid interfering with other transforms)
    result = re.sub(r"<p([^>]*)>", r"<div\1>", result)
    result = result.replace("</p>", "</div>")

    # Gmail spacing: insert <div><br></div> between consecutive paragraph divs
    # This mimics Gmail's native blank-line-between-paragraphs behaviour
    _BR = "<div><br></div>"
    result = re.sub(r"</div>\s*<div>", f"</div>\n{_BR}\n<div>", result)

    # Spacing before block elements: headings, tables, blockquotes
    result = re.sub(
        r"</div>\s*(<div><font size=)",
        f"</div>\n{_BR}\n\\1",
        result,
    )
    result = re.sub(
        r"</div>\s*(<div><b>)",
        f"</div>\n{_BR}\n\\1",
        result,
    )
    result = re.sub(
        r"(?<!</div>\n<div><br></div>)\s*(<table\b)",
        f"\n{_BR}\n\\1",
        result,
    )
    result = re.sub(
        r"(</table>)\s*",
        f"\\1\n{_BR}\n",
        result,
    )
    result = re.sub(
        r"(?<!</div>\n<div><br></div>)\s*(<blockquote\b)",
        f"\n{_BR}\n\\1",
        result,
    )
    result = re.sub(
        r"(</blockquote>)\s*",
        f"\\1\n{_BR}\n",
        result,
    )

    # Clean up: collapse multiple consecutive <div><br></div> into one
    while f"{_BR}\n{_BR}" in result:
        result = result.replace(f"{_BR}\n{_BR}", _BR)

    return result


# ---------------------------------------------------------------------------
# Outlook transform: add inline styles to every element
# ---------------------------------------------------------------------------

_OUTLOOK_FONT = "font-family: Aptos, Calibri, Arial, sans-serif"
_OUTLOOK_SIZE = "font-size: 11pt"
_OUTLOOK_COLOR = "color: #000000"
_OUTLOOK_LINE = "mso-line-height-rule: exactly; line-height: 115%"
_OUTLOOK_BASE = f"{_OUTLOOK_FONT}; {_OUTLOOK_SIZE}; {_OUTLOOK_COLOR}; margin: 0; {_OUTLOOK_LINE}"


def _has_user_style(match_str):
    """Check if a tag already has a style attribute (user-specified)."""
    return 'style="' in match_str or "style='" in match_str


def transform_outlook(html):
    """Add Outlook-compatible inline styles to every element.

    Outlook uses Word's rendering engine which strips <style> blocks
    and does not inherit styles. Every element needs explicit inline styles.
    Only platform defaults are applied - user-specified inline styles are preserved.
    """
    result = html

    # Headings: <h1> -> <p style="font-size:14pt; font-weight:bold; ...">
    result = re.sub(
        r"<h1[^>]*>(.*?)</h1>",
        rf'<p style="{_OUTLOOK_FONT}; font-size: 14pt; font-weight: bold; {_OUTLOOK_COLOR}; margin: 0; mso-line-height-rule: exactly; line-height: 115%;">\1</p>',
        result,
        flags=re.DOTALL,
    )
    result = re.sub(
        r"<h2[^>]*>(.*?)</h2>",
        rf'<p style="{_OUTLOOK_FONT}; font-size: 12pt; font-weight: bold; {_OUTLOOK_COLOR}; margin: 0; mso-line-height-rule: exactly; line-height: 115%;">\1</p>',
        result,
        flags=re.DOTALL,
    )
    result = re.sub(
        r"<h3[^>]*>(.*?)</h3>",
        rf'<p style="{_OUTLOOK_FONT}; {_OUTLOOK_SIZE}; font-weight: bold; {_OUTLOOK_COLOR}; margin: 0; {_OUTLOOK_LINE};">\1</p>',
        result,
        flags=re.DOTALL,
    )
    result = re.sub(
        r"<h[4-6][^>]*>(.*?)</h[4-6]>",
        rf'<p style="{_OUTLOOK_FONT}; {_OUTLOOK_SIZE}; font-weight: bold; {_OUTLOOK_COLOR}; margin: 0; {_OUTLOOK_LINE};">\1</p>',
        result,
        flags=re.DOTALL,
    )

    # Paragraphs: add full inline styles (only those without existing style)
    def _style_p(m):
        tag_content = m.group(0)
        if _has_user_style(tag_content):
            return tag_content
        return f'<p style="{_OUTLOOK_BASE};">'

    result = re.sub(r"<p(?:\s[^>]*)?>", _style_p, result)

    # <del> -> <strike> (Outlook uses <strike>)
    result = re.sub(r"<del([^>]*)>", r"<strike\1>", result)
    result = result.replace("</del>", "</strike>")

    # Code: inline styling
    def _style_code(m):
        inner = m.group(1)
        return f'<code style="font-family: \'Courier New\', Consolas, monospace; font-size: 10pt; {_OUTLOOK_COLOR}; background-color: #f4f4f4; padding: 2pt 4pt;">{inner}</code>'

    result = re.sub(r"<code[^>]*>(.*?)</code>", _style_code, result, flags=re.DOTALL)

    # Pre blocks: style as code blocks
    def _style_pre(m):
        inner = m.group(1)
        return f'<div style="font-family: \'Courier New\', Consolas, monospace; font-size: 10pt; {_OUTLOOK_COLOR}; background-color: #f4f4f4; padding: 8pt; margin: 0 0 12pt 0;">{inner}</div>'

    result = re.sub(r"<pre[^>]*>(.*?)</pre>", _style_pre, result, flags=re.DOTALL)

    # Blockquote: convert to <div> with border-left for Outlook paste compatibility
    # Outlook strips <blockquote> on paste but preserves styled <div>
    def _style_blockquote(m):
        return f'<div style="margin: 0 0 0 4pt; border-left: 3px solid #999999; padding-left: 10pt; {_OUTLOOK_FONT}; {_OUTLOOK_SIZE}; {_OUTLOOK_COLOR};">'

    result = re.sub(r"<blockquote(?:\s[^>]*)?>", _style_blockquote, result)
    result = result.replace("</blockquote>", "</div>")

    # Lists
    def _style_ul(m):
        tag_content = m.group(0)
        if _has_user_style(tag_content):
            return tag_content
        return f'<ul style="margin: 0; padding-left: 20pt; {_OUTLOOK_FONT}; {_OUTLOOK_SIZE}; {_OUTLOOK_COLOR};">'

    def _style_ol(m):
        tag_content = m.group(0)
        if _has_user_style(tag_content):
            return tag_content
        return f'<ol style="margin: 0; padding-left: 20pt; {_OUTLOOK_FONT}; {_OUTLOOK_SIZE}; {_OUTLOOK_COLOR};">'

    def _style_li(m):
        tag_content = m.group(0)
        if _has_user_style(tag_content):
            return tag_content
        return f'<li style="margin: 0; {_OUTLOOK_FONT}; {_OUTLOOK_SIZE}; {_OUTLOOK_COLOR}; {_OUTLOOK_LINE};">'

    result = re.sub(r"<ul(?:\s[^>]*)?>", _style_ul, result)
    result = re.sub(r"<ol(?:\s[^>]*)?>", _style_ol, result)
    result = re.sub(r"<li(?:\s[^>]*)?>", _style_li, result)

    # Tables
    def _style_table(m):
        tag_content = m.group(0)
        if _has_user_style(tag_content):
            return tag_content
        return f'<table border="1" cellpadding="8" cellspacing="0" style="border-collapse: collapse; border: 1px solid #999999; {_OUTLOOK_FONT}; {_OUTLOOK_SIZE};">'

    def _style_td(m):
        tag_content = m.group(0)
        if _has_user_style(tag_content):
            return tag_content
        return f'<td style="border: 1px solid #999999; padding: 8pt; {_OUTLOOK_FONT}; {_OUTLOOK_SIZE}; {_OUTLOOK_COLOR}; {_OUTLOOK_LINE};">'

    def _style_th(m):
        tag_content = m.group(0)
        if _has_user_style(tag_content):
            return tag_content
        return f'<th style="border: 1px solid #999999; padding: 8pt; font-weight: bold; {_OUTLOOK_FONT}; {_OUTLOOK_SIZE}; {_OUTLOOK_COLOR}; {_OUTLOOK_LINE};">'

    result = re.sub(r"<table(?:\s[^>]*)?>", _style_table, result)
    result = re.sub(r"<td(?:\s[^>]*)?>", _style_td, result)
    result = re.sub(r"<th(?:\s[^>]*)?>", _style_th, result)

    # Links
    def _style_a(m):
        tag_content = m.group(0)
        if _has_user_style(tag_content):
            return tag_content
        # Extract href and any other attributes
        href_match = re.search(r'href="[^"]*"', tag_content)
        href = href_match.group(0) if href_match else ""
        return f'<a {href} style="color: #0563c1; text-decoration: underline; {_OUTLOOK_FONT}; {_OUTLOOK_SIZE};">'

    result = re.sub(r"<a\s[^>]*>", _style_a, result)

    # Horizontal rules
    result = re.sub(
        r"<hr\s*/?>",
        '<hr style="border: none; border-top: 1px solid #cccccc; margin: 24pt 0;">',
        result,
    )

    # Outlook spacing: insert &nbsp; spacer before/after block elements only
    # No spacers between consecutive paragraphs - Outlook Classic has tight spacing
    _SPACER = f'<p style="{_OUTLOOK_FONT}; {_OUTLOOK_SIZE}; {_OUTLOOK_COLOR}; margin: 0;">&nbsp;</p>'

    # Spacer before/after tables
    result = re.sub(r"(</p>|</div>)\s*(<table\b)", f"\\1\n{_SPACER}\n\\2", result)
    result = re.sub(r"(</table>)\s*(<p\b|<div\b)", f"\\1\n{_SPACER}\n\\2", result)

    # Spacer before/after blockquote divs (identified by border-left in style)
    result = re.sub(
        r"(</p>)\s*(<div style=\"margin: 0; border-left:)",
        f"\\1\n{_SPACER}\n\\2",
        result,
    )
    result = re.sub(
        r"(</div>)\s*(<p\b)(?=\s+style)",
        f"\\1\n{_SPACER}\n\\2",
        result,
    )

    # Spacer before/after lists
    result = re.sub(r"(</p>|</div>)\s*(<[uo]l\b)", f"\\1\n{_SPACER}\n\\2", result)
    result = re.sub(r"(</[uo]l>)\s*(<p\b|<div\b)", f"\\1\n{_SPACER}\n\\2", result)

    # Spacer before headings (p tags with font-weight: bold)
    result = re.sub(
        r"(</p>|</div>|</[uo]l>|</table>)\s*(<p style=\"[^\"]*font-weight: bold)",
        f"\\1\n{_SPACER}\n\\2",
        result,
    )

    # Clean up: collapse multiple consecutive spacers into one
    while f"{_SPACER}\n{_SPACER}" in result:
        result = result.replace(f"{_SPACER}\n{_SPACER}", _SPACER)

    return result


# ---------------------------------------------------------------------------
# Assembly
# ---------------------------------------------------------------------------

def assemble(shell_path, meta, gmail_body, outlook_body, raw_body):
    """Inject three body versions and metadata into shell template."""
    with open(shell_path, encoding="utf-8") as f:
        html = f.read()

    # Inject metadata fields (order matters - bodies last)
    title = meta.get("title", "HTML Email Preview")
    html = html.replace("<!-- INJECT:PAGE_TITLE -->", title)
    html = html.replace("<!-- INJECT:TO -->", meta.get("to", ""))
    html = html.replace("<!-- INJECT:CC -->", meta.get("cc", ""))
    html = html.replace("<!-- INJECT:BCC -->", meta.get("bcc", ""))
    html = html.replace("<!-- INJECT:SUBJECT -->", meta.get("subject", ""))
    # Bodies last to prevent body content containing marker text
    html = html.replace("<!-- INJECT:GMAIL_BODY -->", gmail_body)
    html = html.replace("<!-- INJECT:OUTLOOK_BODY -->", outlook_body)
    html = html.replace("<!-- INJECT:RAW_BODY -->", raw_body)

    return html


def atomic_write(output_path, content):
    """Write content to file atomically using temp file + rename."""
    output_dir = os.path.dirname(os.path.abspath(output_path))
    fd, tmp_path = tempfile.mkstemp(dir=output_dir, suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            f.write(content)
        os.replace(tmp_path, output_path)
    except Exception:
        os.unlink(tmp_path)
        raise


def main():
    parser = argparse.ArgumentParser(
        description="Assemble fragment into full email preview"
    )
    parser.add_argument("fragment", help="Path to .fragment.md or .fragment.html file")
    parser.add_argument(
        "--serve",
        action="store_true",
        help="Launch preview server after assembly",
    )
    parser.add_argument(
        "--timeout",
        type=int,
        default=600,
        help="Preview server idle timeout in seconds (default: 600)",
    )
    args = parser.parse_args()

    fragment_path = os.path.abspath(args.fragment)
    if not os.path.isfile(fragment_path):
        print(f"Error: {fragment_path} not found", file=sys.stderr)
        sys.exit(1)

    # Resolve shell.html relative to this script
    script_dir = os.path.dirname(os.path.abspath(__file__))
    shell_path = os.path.join(script_dir, "..", "templates", "shell.html")
    shell_path = os.path.normpath(shell_path)

    if not os.path.isfile(shell_path):
        print(f"Error: shell.html not found at {shell_path}", file=sys.stderr)
        sys.exit(1)

    # Parse fragment based on extension
    if fragment_path.endswith(".fragment.md"):
        meta, body_html = parse_fragment_md(fragment_path)
        output_path = fragment_path.replace(".fragment.md", ".html")
    elif fragment_path.endswith(".fragment.html"):
        meta, body_html = parse_fragment_html(fragment_path)
        output_path = fragment_path.replace(".fragment.html", ".html")
    else:
        print("Error: Fragment must be .fragment.md or .fragment.html", file=sys.stderr)
        sys.exit(1)

    validate_fragment(meta, body_html)

    # For .fragment.md: generate platform-specific transforms
    # For .fragment.html: body already has inline styles, use as-is for all three
    if fragment_path.endswith(".fragment.md"):
        gmail_body = transform_gmail(body_html)
        outlook_body = transform_outlook(body_html)
        raw_body = body_html
    else:
        # Legacy: same body for all three (backward compat)
        gmail_body = body_html
        outlook_body = body_html
        raw_body = body_html

    # Safety: if no .fragment in name, append _assembled
    if output_path == fragment_path:
        output_path = fragment_path.replace(".html", "_assembled.html")
        if output_path.endswith(".md"):
            output_path = fragment_path + ".assembled.html"

    # Assemble
    result = assemble(shell_path, meta, gmail_body, outlook_body, raw_body)

    atomic_write(output_path, result)
    print(output_path, flush=True)

    # Optionally launch preview server
    if args.serve:
        preview_script = os.path.join(script_dir, "preview-server.py")
        os.execvp(
            sys.executable,
            [sys.executable, preview_script, output_path, "--timeout", str(args.timeout)],
        )


if __name__ == "__main__":
    main()

```

### scripts/preview-server.py

```python
#!/usr/bin/env python3
"""Minimal HTTP server for previewing Gmail HTML email drafts.

Serves a single HTML file on a random free port with idle auto-shutdown.
No pip dependencies - stdlib only.

Usage:
    python3 preview-server.py /path/to/email.html [--timeout 600]
"""

import argparse
import os
import signal
import socket
import sys
import threading
import time
from http.server import HTTPServer, BaseHTTPRequestHandler


def find_free_port():
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(("", 0))
        return s.getsockname()[1]


_AUTO_REFRESH_SCRIPT = """<script>
(function(){
  var lastMtime = null;
  setInterval(function(){
    fetch('/check-modified').then(function(r){return r.json()}).then(function(d){
      if(lastMtime === null){lastMtime = d.mtime; return;}
      if(d.mtime !== lastMtime){location.reload();}
    }).catch(function(){});
  }, 2000);
})();
</script>"""


class SingleFileHandler(BaseHTTPRequestHandler):
    """Serves only the specified HTML file regardless of request path."""

    html_path = None
    last_request_time = None
    lock = threading.Lock()

    def do_GET(self):
        with self.lock:
            SingleFileHandler.last_request_time = time.time()

        # Serve file modification time as JSON for auto-refresh polling
        if self.path == "/check-modified":
            try:
                mtime = os.path.getmtime(self.html_path)
            except OSError:
                mtime = 0
            payload = f'{{"mtime": {mtime}}}'.encode("utf-8")
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(payload)))
            self.send_header("Cache-Control", "no-cache")
            self.end_headers()
            self.wfile.write(payload)
            return

        try:
            with open(self.html_path, "rb") as f:
                content = f.read()
        except FileNotFoundError:
            self.send_error(404, "File not found")
            return

        # Inject auto-refresh script before </body>
        content_str = content.decode("utf-8")
        if "</body>" in content_str:
            content_str = content_str.replace("</body>", _AUTO_REFRESH_SCRIPT + "\n</body>")
            content = content_str.encode("utf-8")

        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(content)))
        self.send_header("Cache-Control", "no-cache")
        self.end_headers()
        self.wfile.write(content)

    def log_message(self, format, *args):
        pass  # Suppress request logging


def idle_watchdog(server, timeout):
    """Background thread that shuts down the server after idle timeout."""
    while True:
        time.sleep(10)
        with SingleFileHandler.lock:
            elapsed = time.time() - SingleFileHandler.last_request_time
        if elapsed >= timeout:
            print(f"\nIdle for {timeout}s - shutting down.", flush=True)
            server.shutdown()
            return


def main():
    parser = argparse.ArgumentParser(description="Preview HTML email drafts")
    parser.add_argument("file", help="Path to HTML file to serve")
    parser.add_argument(
        "--timeout",
        type=int,
        default=600,
        help="Idle timeout in seconds (default: 600)",
    )
    args = parser.parse_args()

    html_path = os.path.abspath(args.file)
    if not os.path.isfile(html_path):
        print(f"Error: {html_path} not found", file=sys.stderr)
        sys.exit(1)

    SingleFileHandler.html_path = html_path
    SingleFileHandler.last_request_time = time.time()

    port = int(os.environ.get("PORT", 0)) or find_free_port()
    server = HTTPServer(("127.0.0.1", port), SingleFileHandler)

    # Clean shutdown on signals
    def handle_signal(signum, frame):
        print("\nShutting down.", flush=True)
        threading.Thread(target=server.shutdown).start()

    signal.signal(signal.SIGINT, handle_signal)
    signal.signal(signal.SIGTERM, handle_signal)

    # Start idle watchdog
    watchdog = threading.Thread(
        target=idle_watchdog, args=(server, args.timeout), daemon=True
    )
    watchdog.start()

    url = f"http://127.0.0.1:{port}/"
    print(url, flush=True)

    server.serve_forever()
    server.server_close()


if __name__ == "__main__":
    main()

```

message | SkillHub