mailshot

Templates

Bring your own email templates - React Email, Maizzle, MJML, plain HTML, or any tool. LiquidJS handles variables at send time.

Templates are HTML files with LiquidJS placeholders. At runtime, LiquidJS fills in subscriber data before sending. How you produce the HTML is entirely up to you.

Bring your own templates

mailshot doesn't care how your HTML is created. Use whatever tool or workflow you prefer:

  • React Email - JSX components, included in the scaffolded project
  • Maizzle - Tailwind CSS for email
  • MJML - responsive email markup language
  • Drag-and-drop builders - export HTML from Beefree, Stripo, Mailchimp, or any builder
  • Plain HTML - hand-write it, copy it from an existing system, or generate it with AI
  • Existing templates - already have email templates? Drop them in as-is

The only contract: your build step must output .html files to build/<sequenceId>/templates/. Those HTML files can contain LiquidJS syntax ({{ firstName }}, {% if ... %}, etc.) for personalization - that's the one thing that's fixed. Everything else is your choice.

What the render step does

Each sequence has a src/render.ts script. Its job is simple: take your source files (whatever format they're in) and write .html files to the build directory. The scaffolded project uses React Email by default, but you can swap this out for any build pipeline - or skip it entirely and just copy pre-built HTML files.

Lifecycle

Source files (React Email, Maizzle, MJML, plain HTML, anything)
  │  render step (your build pipeline)

.html (static HTML with {{ liquid }} placeholders)
  │  CDK BucketDeployment

S3 (stored at <sequenceId>/<templateName>.html)
  │  SendEmailFn fetches at runtime (10min cache)

LiquidJS renders with subscriber data


SES sends the final HTML

Writing a template

The scaffolded project uses React Email by default. Templates live at sequences/<sequenceId>/src/emails/<templateName>.tsx.

import { Body, Container, Head, Html, Preview, Text, Link } from "@react-email/components";

interface Props {
  firstName?: string;
  unsubscribeUrl?: string;
}

export default function Welcome({
  firstName = "{{ firstName }}",
  unsubscribeUrl = "{{ unsubscribeUrl }}",
}: Props) {
  return (
    <Html>
      <Head />
      <Preview>Welcome aboard</Preview>
      <Body>
        <Container>
          <Text>Hey {firstName},</Text>
          <Text>Thanks for signing up.</Text>
          <Text style={{ fontSize: "12px", color: "#666" }}>
            <Link href={unsubscribeUrl}>Unsubscribe</Link>
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

Key conventions:

  • Default prop values use Liquid syntax: firstName = "{{ firstName }}". This means the React Email dev server shows the Liquid placeholders, and at build time they're baked into the HTML.
  • Always include a <Preview> component for preheader text.
  • Always include an unsubscribe link using {{ unsubscribeUrl }}.

Using plain HTML instead

If you'd rather skip React Email, just write HTML directly. Create .html files and update render.ts to copy them to the build directory:

<!-- sequences/onboarding/src/emails/welcome.html -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
  </head>
  <body>
    <h1>Hey {{ firstName }},</h1>
    <p>Thanks for signing up.</p>
    <p><a href="{{ unsubscribeUrl }}">Unsubscribe</a></p>
  </body>
</html>
// sequences/onboarding/src/render.ts
import { copyFileSync, mkdirSync } from "fs";
import { resolve } from "path";

const src = resolve(__dirname, "emails");
const out = resolve(__dirname, "../../build/onboarding/templates");
mkdirSync(out, { recursive: true });

copyFileSync(resolve(src, "welcome.html"), resolve(out, "welcome.html"));

This works identically - the same Liquid variables, the same S3 upload, the same send pipeline. The source format doesn't matter, only the final .html output.

Liquid syntax at runtime

The rendered HTML contains Liquid placeholders that LiquidJS evaluates at send time. You have access to all subscriber attributes:

<p>Hey {{ firstName }},</p>

{% if platform == "kajabi" %}
<p>Here's how to connect your Kajabi checkout...</p>
{% endif %} {% for item in cartItems %}
<p>{{ item.name }} - ${{ item.price }}</p>
{% endfor %}

<a href="{{ unsubscribeUrl }}">Unsubscribe</a>

Available variables

VariableSource
firstNameSubscriber profile
emailSubscriber profile
unsubscribeUrlGenerated by SendEmailFn (HMAC-signed, 90-day expiry)
Any attribute keyFrom subscriber.attributes in DynamoDB
currentYearCurrent year (e.g., 2026)

These variables are available in both template bodies and subject lines. Subject lines use the same Liquid syntax:

{ type: "send", templateKey: "onboarding/welcome", subject: "Hey {{ firstName }}, welcome!" }

Display names

If you need human-readable labels for attribute values (e.g., showing "Kajabi" instead of "kajabi"), upload a JSON mapping to S3. The display-names lib module loads these with a 10-minute cache.

Template keys

A template key is the S3 path without the .html extension:

templateKey: "onboarding/welcome"
→ S3 path: s3://bucket/onboarding/welcome.html

Template keys in the sequence config must match the rendered HTML filename exactly.

Build step

Each sequence has a render.ts script that converts source files to .html:

pnpm --filter <sequenceId> build

This runs tsc (compile TypeScript) then pnpm render (execute render.ts). Output goes to build/<sequenceId>/templates/. The default scaffolded project uses @react-email/render, but you can replace render.ts with any pipeline - Maizzle build, MJML compile, or a simple file copy.

Dev server

Preview templates in the browser with hot reload:

pnpm --filter <sequenceId> dev

Opens React Email dev server on port 3002. Shows all templates in src/emails/ with live preview.

Previewing with subscriber data

Use the MCP server's preview_template tool to render a template with real subscriber data:

preview_template(templateKey: "onboarding/welcome", email: "user@example.com")

This fetches the template from S3 and renders it with the subscriber's actual profile attributes.

Validating templates

Use the MCP server's validate_template tool to check that a template exists in S3 and renders without errors:

validate_template(templateKey: "onboarding/welcome")