Skip to content

Markdown Mailables

Instead of hand-rolling HTML for every email, write the body as plain CommonMark markdown. Postal converts it to HTML, derives a plain-text fallback from that same source, wraps the result in a shared page layout, and gives you pre-styled components for things like buttons and callout panels — so your emails stay visually consistent without every mailable reinventing its own markup.

Quick start: Mailable::markdown()

The fast path is a single call in your Mailable's build(): point markdown() at a view file, and it handles conversion, layout, and the plain-text fallback for you.

<?php

namespace App\Mails;

use Myth\Postal\Mailable;

class WelcomeEmail extends Mailable
{
    public function __construct(private readonly User $user)
    {
        parent::__construct();
    }

    protected function build(): void
    {
        $this->from('hello@example.com', 'Acme')
            ->to($this->user->email, $this->user->name)
            ->subject('Welcome aboard')
            ->markdown('emails/welcome', ['user' => $this->user]);
    }
}

emails/welcome is a plain CI4 view file (app/Views/emails/welcome.php) written in markdown, not HTML — headings, bold text, links, and the Mail Components below all just work. Behind the scenes, ->markdown():

  1. Resolves the view and converts it from markdown to HTML.
  2. Wraps that HTML in a Layout — a shared page template.
  3. Inlines the layout's CSS, so styling survives in email clients that strip <style> blocks.
  4. Sets both outputs on the Mailable: html() with the rendered HTML, text() with a plain-text fallback generated from the markdown source.

From the outside, a Mailable calling ->markdown(...) behaves exactly like one calling ->html()->text() directly — same Email::$htmlBody/$textBody, same Mailer::fake()/assertSent() surface.

Want a different layout for one Mailable? Call layout() before markdown():

<?php

protected function build(): void
{
    $this->from('news@example.com')
        ->to($this->user->email)
        ->subject('This month at Acme')
        ->layout('mail/layouts/marketing')
        ->markdown('emails/newsletter', ['user' => $this->user]);
}

markdown() resolves the layout the moment it runs, so layout() has to come first — call it afterward and Postal throws a Myth\Postal\Exceptions\PostalException instead of silently ignoring it.

Generate the scaffolding for you

php spark make:mailable Welcome --markdown scaffolds both the Mailable class and a starter markdown view, with build() already wired up to call ->markdown(). See Mailables.

Mail Components

Mail Components are reusable, pre-styled elements you drop straight into your markdown using mail--prefixed tags. Postal ships two: a button and a panel.

<mail-button url="https://example.com/confirm">Confirm Email</mail-button>

They aren't a regex trick — Mail Component tags are parsed as real nodes in the markdown document, right alongside headings and lists. There's nothing to enable; they're always available inside any markdown view.

A tag's attributes become the component view's data, and its inner content becomes a $slot variable, so the example above resolves to:

view('mail/components/button', ['url' => 'https://example.com/confirm', 'slot' => 'Confirm Email'])

Single-line vs. multi-line

Write a component two ways, depending on how much content it needs to wrap:

<mail-button url="https://example.com/confirm">Confirm Email</mail-button>

Everything between the tags is inline content — **bold**, links, and code spans all work, but nested Mail Components don't.

<mail-panel color="#fef3c7">
Heads up — your trial ends **Friday**.

[Upgrade now](https://example.com/upgrade) to keep your data.
</mail-panel>

The closing tag lives on its own line. Everything in between is parsed as full markdown, including multiple paragraphs and nested Mail Components.

Keep the closing tag clean

On a single-line tag, the closing </mail-...> has to be the last thing on the line. Write something after it — <mail-button url="...">Confirm</mail-button> and click here — and Postal won't recognize it as a Mail Component at all. It falls back to plain HTML in the markdown, tag and all.

The shipped components

Component Attributes Notes
<mail-button> url (required) Renders a styled link. Omitting url throws a Myth\Postal\Exceptions\PostalException with a clear message, rather than rendering a broken link.
<mail-panel> color (optional) A callout box. Takes a hex value (#fef3c7) or a CSS colour keyword (linen); anything else falls back to the default light blue, since the value lands directly in a style attribute.

Both are styled with inline style="" attributes directly on their own markup, rather than relying solely on the layout's CSS inlining — so they still look right even in clients that strip <style> blocks entirely.

Components in the plain-text part

The plain-text fallback can't show a button or a panel, so each component turns into plain text there. Postal drops the tags and keeps the slot content, with its lines, list items and paragraphs. If a component has a url attribute, the URL follows the slot in parentheses, the same way a markdown link does:

Confirm Email (https://example.com/confirm)

A component with an empty slot leaves just its url, or nothing if it has none. The text comes from the markdown source, not from the component's view. So anything the view adds on its own, such as fixed wording or a URL it builds from other attributes, won't be in the text. When that matters, call ->text() after ->markdown() with your own text. An explicit text() always replaces the generated one.

Want to restyle them, or add your own? Config\Postal::$componentViewPath (default 'mail/components') controls where <mail-{tag}> looks for its view — <mail-button> resolves to {componentViewPath}/button. See the tip below for copying the shipped views into your own app.

Customizing the Layout

A Layout is the shared page template your markdown gets wrapped in — a single slot, filled in with the HTML your markdown converted to. It's applied after conversion, which is a deliberate difference from CodeIgniter's own extend()/section(): those compose at the raw view level, before anything's been converted, and don't give Postal a clean point to run CSS inlining or resolve Mail Components against the final HTML.

The default layout ships at mail/layouts/default, and receives the converted content as $content:

<!-- app/Views/mail/layouts/default.php -->
<!doctype html>
<html>
<head>
<style>
  body { font-family: sans-serif; background: #f4f4f5; }
  .container { max-width: 600px; margin: 0 auto; padding: 24px; }
</style>
</head>
<body>
  <div class="container">
    <?= $content ?>
  </div>
</body>
</html>

Make it yours

Run php spark publish to copy the default Layout and Mail Components into your app — see Installation — then edit the copies in app/Views/mail/ freely. Postal always prefers your app's version over its own, and publishing never overwrites a file you've already customized.

Mailable::markdown() uses Config\Postal::$defaultLayout (default 'mail/layouts/default') unless you override it per-Mailable with layout(), as shown in the quick start above.

The <style> block stays

CSS inlining adds style="" attributes for clients that need them, but it doesn't strip the original <style> block — clients that do render <head> styles get the benefit of both.

Advanced: converting markdown directly

Mailable::markdown() covers the common case, but its two building blocks — the markdown() helper and LayoutRenderer — are useful on their own too, if you want converted HTML without the full pipeline.

The markdown() helper resolves a view file to raw markdown and converts it in one call. It's a global helper, always available — no helper() call needed, same as CodeIgniter's own view():

<?php

$rendered = markdown('emails/welcome', ['name' => $user->name]);

echo (string) $rendered;   // the converted HTML
echo $rendered->text();    // a plain-text fallback, derived from the same markdown source

If you already have a markdown string rather than a view file, call the conversion service directly:

<?php

$html = service('markdown')->toHtml('# Hello **World**');
$text = service('markdown')->toText('# Hello **World**');

Config\Postal::$markdownExtensions controls which CommonMark extensions load on top of the core parser. By default that's GitHub-Flavored Markdown (tables, strikethrough, autolinks):

<?php

public array $markdownExtensions = [
    League\CommonMark\Extension\GithubFlavoredMarkdownExtension::class,
];

To apply a Layout yourself — without going through a Mailable — use LayoutRenderer, which wraps content in a Layout and inlines its CSS:

<?php

use Myth\Postal\Markdown\LayoutRenderer;

$html = (new LayoutRenderer())->render((string) $rendered);

That uses Config\Postal::$defaultLayout too, unless you pass a second argument to use a different layout for just this call:

<?php

$html = (new LayoutRenderer())->render((string) $rendered, 'mail/layouts/marketing');

Security: raw HTML passthrough

CommonMark's raw-HTML passthrough is left at its default (allow): any HTML you write directly in a markdown view — including a value interpolated without esc() — reaches the rendered email unescaped, exactly as it would in a regular CI4 view.

Escape untrusted data yourself

Markdown mailables carry the same trust model as any other CodeIgniter view: nothing here sanitizes interpolated values automatically. If a markdown view interpolates data a user controls — a name, a comment, a URL — wrap it in esc() yourself, exactly as you would in a regular .php view:

<?php
// app/Views/emails/comment-notification.php
?>
New comment from **<?= esc($comment->authorName) ?>**:

> <?= esc($comment->body) ?>

Skipping this is no more or less risky than skipping esc() in an HTML view — but it's easy to assume markdown is "just text" and let your guard down. It isn't: it compiles to HTML, and unescaped interpolation compiles right along with it.

Config reference

The markdown settings — $markdownExtensions, $componentViewPath and $defaultLayout — are listed in the Config\Postal reference.

Next steps

  • Mailables — the class-based email Mailable::markdown() plugs into
  • MIME Rendering — how the HTML and text bodies a Mailable produces become a wire-ready message