Skip to content
Cowliss is in beta: features and pricing may change.

Email templates

This page is the template model: what a template in your repo’s emails/ directory holds, and what an automation or a send passes it. Looking for the fields of the send call itself? That is emails.send.

A template’s key is its file name under emails/, and it comes in two kinds:

  • Liquid HTML: emails/<key>.html, front matter over an HTML body written in Liquid. Loops and conditionals live in the template; values arrive as finished strings in the props.
  • A doc: emails/<key>.md, markdown with front matter, or emails/<key>.maily.json, a doc the editor exported. It renders in the app’s look, the same way a broadcast does.

A sendEmail step names a template by key and passes props, and never knows which kind it is. Both kinds are written the same ways: a file in the repo pushed with cow push, the dashboard’s editor, or templates.create and templates.update over the API, the CLI or MCP, which take either kind as JSON. Reading a template, previewing it, editing it and sending yourself a test is Templates in the dashboard.

If your templates are React Email components, keep them: cow push renders each emails/<key>.tsx into Liquid HTML for you. See Writing it as a React Email component.

emails/order-shipped.html
---
subject: Order {{ orderId }} is on its way
purpose: transactional
preview: Tracking is inside
tags: [commerce]
---
<!DOCTYPE html>
<html lang="en">
<body style="font-family:Arial, sans-serif;color:#27272a">
<p>Hi {{ firstName | default: "there" }},</p>
<p>Order {{ orderId }} left the warehouse today.</p>
{% if items.size > 0 %}
<table role="presentation">
{% for item in items %}
<tr><td>{{ item.name }}</td><td>{{ item.price }}</td></tr>
{% endfor %}
</table>
{% endif %}
{% if trackingUrl %}
<a href="{{ trackingUrl }}">Track the parcel</a>
{% endif %}
</body>
</html>
Front matter Required What it is
subject yes The subject line, in Liquid like the body.
purpose yes Why it is sent: transactional, or a path your cow.json declares. See Purposes.
preview no The line inboxes show after the subject.
tags no Your own labels, shown beside the template in the dashboard: a comma list, bracketed or not. At most 20, each at most 50 characters, kept as written.

Everything after the closing --- is the body, kept exactly as written. The props the automation’s sendEmail step passes are the template’s whole scope: {{ firstName }} is the firstName prop, and nothing else is merged in behind it. If the mail says “Hi Ada”, the automation passes firstName, read from the profile with { "var": "profile.traits.firstName" }. Cowliss adds two kinds of value itself, the verify link and the unsubscribe and preferences links, when the template mentions them.

At send time the platform renders the latest version of the template. A template has no switch: pushing a fix is all it takes, and the next send uses it, including sends from executions already in flight. A prop that a running automation stopped passing prints empty rather than failing the send, so rename a prop in the same push as the automation that passes it.

  • Variables: {{ firstName }} prints a prop, {{ order.total }} a field of one, {{ items[0].name }} an element. A prop the step did not pass prints empty.
  • A fallback: {{ firstName | default: "there" }} prints there when firstName is missing or empty.
  • Conditions: {% if trialEnds %}...{% elsif plan == "pro" %}...{% else %}...{% endif %}, and {% unless %} for the negative. "" and 0 read as false, as they do in JavaScript, so {% if draftNotice %} skips an empty string. An empty list reads as true: test {% if items.size > 0 %}.
  • Loops: {% for item in items %}...{% endfor %}, with {{ forloop.index }} for the position and {% else %} for an empty list.
  • Escaping: every {{ }} in the body is HTML-escaped, so a prop holding <b> arrives as text. {{ html | raw }} prints it as markup, for HTML you built and trust. The subject is a header, not HTML, so it is printed as is.
  • Filters: the standard LiquidJS filters (default, upcase, date, join, size, escape and the rest) and no others. A filter that does not exist is an error naming it, not an empty string. There are no custom filters, so a price, a date in the reader’s language or a percentage arrives as a finished string in the props: compute it in the automation’s props or in your app.
  • One file: {% include %}, {% render %} and {% layout %} are refused. A template is one file, so a layout two templates share is written into both.

A doc template has its own variable syntax, {{name|fallback}} with no spaces and no filters. The two are not interchangeable: Liquid’s fallback is {{ name | default: "fallback" }}.

cow plan and cow push parse every Liquid template and render it once with no props before anything is sent, so a tag left open or a misspelled filter stops the push with the file and the line named.

emails/trial-ending.md
---
subject: Your trial ends soon
purpose: transactional
header: logo_and_name
preview: Pick a plan to keep what you set up
tags: [billing]
---
Hi {{firstName|there}},
Your trial ends on **{{endsOn}}**. Pick a plan to keep everything you set up.
[Choose a plan](https://example.com/billing){button}
Front matter Required What it is
subject yes The subject line. It takes the body’s variables, {{name}} and `{{name
purpose yes Why it is sent, exactly as a Liquid template’s.
header no What sits above the content: logo_and_name, name, logo, or none (the default), drawn from the app’s Brand.
preview no The line inboxes show after the subject.
tags no A comma list, bracketed or not.

The body is a small markdown: # to ### headings, paragraphs, **bold**, *italic*, [links](url), an image by absolute URL on a line of its own, - and 1. lists, > for a quote, --- for a rule, and [label](url){button} on a line of its own for a button. {{name}} is a variable and {{name|fallback}} one with a fallback. A doc the markdown cannot say, such as one built in the dashboard editor with columns, is emails/<key>.maily.json instead, and cow pull writes whichever of the two holds the doc without loss. In that JSON form, and in a doc written over the API, a variable is a variable node ({ "type": "variable", "attrs": { "id": "firstName", "fallback": "there" } }) in a paragraph, a heading or a button’s content; a {{firstName}} inside a text node is plain text there. Attributes such as a button’s url or an image’s src do take {{name|fallback}}, and so does the subject.

A doc’s variables are the person’s traits with the step’s props laid over them, props winning, so {{firstName}} works with no props at all. A doc formats nothing and has no loops: pass money, dates and percentages as finished strings, and write a template that loops over a list as Liquid HTML.

emails/<key>.tsx is a convenience for teams whose templates are already React Email components. cow push renders each one once, with no props, and pushes the HTML that comes out as a Liquid template. Nothing about a .tsx reaches the server; the server only ever holds the HTML.

emails/order-shipped.tsx
import type { Purpose } from "@cowliss/cli/emails";
import { Html, Text } from "@react-email/components";
export const subject = (p: { orderId: string }) =>
`Order ${p.orderId} is on its way`;
export const purpose: Purpose = "transactional";
export default function OrderShipped() {
return (
<Html>
<Text>Hi {'{{ firstName | default: "there" }}'},</Text>
{"{% for item in items %}"}
<Text>
{"{{ item.name }}"}: {"{{ item.price }}"}
</Text>
{"{% endfor %}"}
</Html>
);
}
  • The JSX prints Liquid. A tag is a string in the JSX, written where its output should land. React escapes the quotes and angle brackets inside it, and cow push decodes them back inside {{ }} and {% %} and nowhere else, so default: "there" reaches the server as written.
  • A subject function reads props as Liquid. (p) => `Order ${p.orderId}` becomes Order {{ orderId }}: every prop it reads prints as a variable. A method called on a prop is refused with the file named, and a condition over a prop always takes its first branch, because every prop reads as present. For a subject that depends on a prop, export a plain string with the Liquid in it, or pass the finished string as a prop.
  • purpose, tags and preview are exports with the meaning of the front matter keys above. purpose is required.

The ceiling: nothing computed from data survives the render, because there is no data. {p.firstName ?? "there"} renders as the constant there, and p.items.map(...) stops the push, because items is not there. Every loop, every condition and every number a chart needs is either a Liquid tag in the JSX or a finished string in the automation’s props.

cow pull never writes over a .tsx: a key with a local emails/<key>.tsx is skipped and listed, because the component is the source and the HTML its output.

A template says why it is sent, and that is what decides who it reaches:

  • "transactional": mail nobody can turn off, such as a receipt, a password reset or an access change. It skips consent and the frequency cap, and carries no unsubscribe link. Every other gate still applies: a suppressed address, a paused org, an empty wallet, and an unverified sending domain all still stop the send.
  • "marketing.<name>", such as "marketing.newsletter": off until your app records a yes, both to the name and to marketing.
  • "transactional.<name>", such as "transactional.weeklyReport": mail about the person’s own account that they may still want to stop. On until they turn off the name or transactional.

Use bare "transactional" only for mail that must reach the person whatever they chose: password recovery, email verification, receipts, security and access notices. If they could do without it, such as a weekly report, a usage digest or a reminder, give it a transactional.<name>: it still starts on, but they can leave it, and a spam complaint about it turns off that name alone.

Every name is declared in cow.json with the label a recipient reads beside its switch, and cow plan and cow push check every template’s purpose against that list, so a misspelled path stops the push with the file named. Bare "marketing" is refused: it is the switch for every marketing email, so each one names what it is and can be left on its own.

Each app declares its own purposes. Before 0.17 one app’s declaration served the whole organization; 0.17 kept each purpose on the app that declared it, as marketing.<name>, with every profile’s answer kept under that name. Another app that sent under it declares marketing.<name> in its own cow.json and pushes, and its profiles’ answers count again.

The purpose is a property of the template, not of the send call or the automation, so one message cannot go out under two purposes. An automation’s own purpose only decides how it enrolls. See Sending and delivery for the gate order and the skip reasons.

The one-click unsubscribe token is never something you mint: it rides in the RFC 8058 List-Unsubscribe headers the send path attaches to everything someone can leave, with a signed URL minted per message. Whether the link itself shows up in the body is yours, and is covered in Unsubscribe links below.

A template that mentions verifyUrl anywhere, in its subject or its body, receives it at send time without the automation passing it: a signed, expiring link that marks the recipient’s current address verified when opened. The platform mints it against the address the message is actually going to, and a resend a day later carries a fresh token rather than the first one. A template that never mentions it gets none.

<a href="{{ verifyUrl }}">Confirm your address</a>

A doc reads it the same way, as {{verifyUrl}}.

Verification state is Cowliss’s to write, not yours. Cowliss keeps every address a person has held and how each was verified: a link they clicked, an address you imported as already verified, or their auth provider’s own flag. Setting emailVerifiedAt or emailVerifiedAddress through identify is a 422. Moving somebody to a new address clears verification; moving them back to one already verified restores it.

Every email under a purpose someone can leave already carries the RFC 8058 List-Unsubscribe headers, so Gmail and Apple Mail show their own one-click link in the client chrome. The body is a separate question, and there are two ways it goes:

  • You render the links. A template that mentions unsubscribeUrl (leave this purpose), unsubscribeAllUrl (leave everything) or preferencesUrl (manage every purpose, see Email preferences) receives each one it mentions at send time, the same way as verifyUrl: never passed by the automation, a fresh token per message.

    <p style="font-size:12px;color:#71717a">
    <a href="{{ preferencesUrl }}">Manage preferences</a> ·
    <a href="{{ unsubscribeUrl }}">Unsubscribe from the newsletter</a> or
    <a href="{{ unsubscribeAllUrl }}">from everything</a>.
    </p>

    Both unsubscribe links open a confirmation page that changes nothing by itself. Its prominent choice is Keep me subscribed, which leaves everything as it was; only the quieter Unsubscribe button makes the change, so a mistaken click costs nothing. The first link turns off the purpose the mail was sent under, nothing else. The second turns off both roots, marketing and transactional, and every name under them; receipts and other bare transactional mail keep arriving, and the page says so.

    Every one of these pages names the app the mail came from, by its business name when it has one, and links back to its website when it has one, in the app’s look and language: see Brand.

    The page after the click offers Resubscribe for 48 hours. It puts back exactly what that click turned off and nothing else: a marketing purpose the recipient never granted stays off, and so does a purpose they had already turned off before. Pressing it twice is the same as once. Below it, an optional Tell us why form asks for a reason (“I didn’t sign up for these”, “Too many emails”, “Not relevant to me”, “Other”) and a short comment; the answer lands once on the recipient’s timeline as a system.unsubscribe_feedback event and changes no consent. The page after leaving one purpose also offers leaving everything, as the quieter choice.

  • You leave them out. If the message’s HTML contains none of the three links anywhere, the platform appends a small, muted, centered footer after your message before sending: the address it was sent to, why they get it (the purpose’s label), “Update your email preferences | Unsubscribe”, then your app’s public name, website and the Brand’s postal address. “Unsubscribe” leaves this purpose, and the preferences page offers leaving everything; a recipient with no preferences page gets “Unsubscribe from everything” beside it instead. The text part carries the same lines and URLs, so the recipient always has a way to leave. A template that renders any one of the three is left untouched and keeps only what it rendered, so render all three if you want all three offered. Dry runs and test sends show the footer exactly as a real send would carry it.

A doc template always takes the footer. The footer is written in the app’s Brand language. A bare "transactional" template gets none of this: no headers, no footer. An unsubscribe link on a receipt is wrong.

An app whose marketing consent lives in another system can hand the leave to that system: it names a profile trait holding each recipient’s own unsubscribe URL, and a marketing email to a recipient who carries one links there instead, in the footer, both link props and the one-click header, with no preferences link. See Your own unsubscribe.

Cowliss hosts a preferences page for each recipient: one switch for every purpose their app declares in cow.json, with its label and description, each showing what holds now. Bare transactional mail is not on it, and the page says receipts and account emails keep arriving. It is in the app’s Brand look and language, names the app by its business name, links back to its website, and offers Unsubscribe from everything with the same Resubscribe undo as the link’s.

Opening the page changes nothing. Saving writes only the switches it showed and the person changed: each one turned off writes system.consent_revoked and each one turned on writes system.consent_granted, both with reason preferences, and turning a name on turns its root on, as granting it through identify does. These are the recipient’s own answers, so they replace whatever your app set before, the same as an unsubscribe does.

There are two ways in:

  • From an email. The footer’s “Update your email preferences” link, or {{ preferencesUrl }} in the template. It works for 30 days: unlike an unsubscribe link it can turn mail on, so an old or forwarded message must stop opening it. The unsubscribe links beside it keep working forever.

  • From your app. Your server asks for a link when the person clicks “Manage email preferences”, with the same key and source as cow.emails.send, and redirects them to it. It works for an hour.

    const { url, expiresAt } = await cow.preferences.link({ to: { userId: "shop_1" } });
    // url: https://api.cowliss.com/v1/preferences?token=...
    // expiresAt: "2026-10-01T16:00:00.000Z"

    to names one profile in the source’s app the way a send to a profile does, and is refused the same way: 404 for an identifier no profile in that app holds, 422 for anything but exactly one identifier.

A template is one language. To send in French, push a French template beside the English one, emails/welcome.html and emails/welcome-fr.html, and let the automation pick by the person’s locale trait with a branch:

{
"id": "by-language",
"type": "branch",
"if": { "==": [{ "var": "profile.traits.locale" }, "fr"] },
"then": [{ "id": "welcome-fr", "type": "sendEmail", "template": "welcome-fr" }],
"else": [{ "id": "welcome", "type": "sendEmail", "template": "welcome" }]
}

The footer Cowliss appends is in the language set on the app’s Brand, one per app, so a template in any other language renders its own links. Point “Manage notifications” at your app’s own preferences page and keep the minted unsubscribeUrl for leaving this purpose. Once the HTML carries one of the minted links, no footer is added:

emails/welcome-fr.html
---
subject: Bienvenue
purpose: marketing.newsletter
---
<p>Bonjour {{ firstName | default: "à vous" }},</p>
<p>
<a href="https://acme.com/compte/notifications">Gérer mes notifications</a> ·
<a href="{{ unsubscribeUrl }}">Se désabonner de la newsletter</a>
</p>

The List-Unsubscribe headers still point at Cowliss’s one-click unsubscribe, so the mail client’s own link keeps working. The hosted unsubscribe and preferences pages are in the app’s language, one per app, set in Brand.

Rendering happens on the platform, at send time and whenever you preview or test-send a template: a Liquid template with LiquidJS, the step’s props as its scope, and a doc in the same renderer broadcasts use. Either produces { subject, html, text }: the text part is derived from the HTML, so the two never drift, with images and elements marked data-skip-in-text="true" left out. Your own machine renders nothing that is sent; cow push only turns a .tsx into the HTML it pushes.

Two consequences worth knowing:

  • Inline styles only. Email clients strip Tailwind, external stylesheets, and <style> blocks alike, so every style is a style attribute on the element it styles.
  • A template reads its props and nothing else. No fetch, no clock, no profile lookup: what it needs, the automation passes.

To read the actual mail without sending it to anyone, run the automation as a dry run: the render happens exactly as it would on a real send and then the message stops, recorded as would_send with the rendered subject, HTML, and text, which the dashboard shows in full. A template’s test send does the same for one template, to your own address. See Testing automations.

A Liquid template’s HTML holds at most 524,288 characters (512 KB). Its render gets 5 seconds and 64 MB of memory, and must produce at most 512 KB of HTML. A render that fails, or exceeds one of these, fails that send with the reason on the execution rather than sending something truncated.

cow.emails.send renders a template by key, or an inline body when no template fits. A bare transactional template goes to an address; one with a purpose someone can leave goes to a profile. The whole payload, repeated here so you do not have to leave the page:

Field Required Notes
to yes One address, or a list of up to 50: "[email protected]" or "Ada <[email protected]>". Or one identifier naming a profile, { userId: "shop_1" }.
cc no One address, or a list. Refused when to is a profile.
bcc no Same as cc.
replyTo no One address, or a list.
from yes An address on a domain your organization verified. There is no default.
template either { key, props }: a template you pushed, and the props it reads. It writes the whole message, subject included.
subject either Required when you write the body yourself; refused beside a template.
html / text either The inline body, one or both. Refused beside a template.
headers no Passed on as you wrote them.
sourceId on the wire The client’s, unless the call names one.

Answers { deliveryId }.

The rules behind it (credit, suppression, the from-address, and why a template its recipients can turn off needs a profile) are on emails.send.