Documentation

Liquid Template Reference

MailQ email templates use the Liquid templating language. Subject lines and HTML bodies are rendered at send time with the parameters you supply. Variables output as empty when undefined — renders never fail due to missing data.

Output

Wrap any expression in double curly braces to render its value. The HTML body is automatically HTML-encoded to prevent markup injection — the subject line is plain text and is not encoded.

{{ firstName }}
{{ user.name }}
{{ order.total | prepend: "€" }}

Dot notation accesses nested properties. Bracket notation works too and is useful for keys with special characters or dynamic lookups.

{{ user.address.city }}
{{ product["price-eur"] }}
{{ items[0].name }}

Variables & Types

Parameters are passed as a JSON object when sending. All JSON types are available in templates: strings, numbers, booleans, arrays, and objects.

Send-time parameters (example)

{
  "user": {
    "name": "Ada Lovelace",
    "vip": true,
    "points": 1200
  },
  "items": [
    { "name": "Shirt", "price": 49.90 },
    { "name": "Trousers", "price": 89.00 }
  ],
  "discount": 0.10
}

Accessing values in the template

{{ user.name }}          {# → Ada Lovelace #}
{{ user.vip }}           {# → true #}
{{ user.points }}        {# → 1200 #}
{{ items[0].name }}      {# → Shirt #}
{{ items.size }}         {# → 2 #}
{{ discount | times: 100 }} {# → 10.0 #}
ⓘ

Accessing a property that does not exist returns an empty string — it does not cause an error. Use the default filter to provide a fallback value.

Default values

{{ user.nickname | default: user.name }}
{{ discount | default: 0 }}
{{ greeting | default: "Hello" }}

Conditionals

Use if / elsif / else to branch on any truthy value. Empty strings, nil, and false are falsy; everything else is truthy.

{% if user.vip %}
  <p>Welcome back, VIP member!</p>
{% elsif user.active %}
  <p>Welcome back, {{ user.name }}.</p>
{% else %}
  <p>Hello there!</p>
{% endif %}

unless

unless is the inverse of if — the block renders when the condition is false.

{% unless user.unsubscribed %}
  <a href="{{ unsubscribeUrl }}">Unsubscribe</a>
{% endunless %}

Comparison operators

==Equal{% if status == "active" %}
!=Not equal{% if plan != "free" %}
>Greater than{% if points > 1000 %}
<Less than{% if balance < 0 %}
>=Greater than or equal{% if age >= 18 %}
<=Less than or equal{% if items.size <= 5 %}
containsString / array contains{% if tags contains "promo" %}

Boolean operators

{% if user.vip and order.total > 100 %}
  <p>Free shipping applied.</p>
{% endif %}

{% if user.guest or user.trial %}
  <p>Sign up for full access.</p>
{% endif %}

Inline if (ternary-style with assign)

{% assign label = "Standard" %}
{% if user.vip %}{% assign label = "VIP" %}{% endif %}
<span>{{ label }}</span>

Loops

Iterate over arrays with for … in …. The loop variable is scoped to the block.

<ul>
  {% for item in items %}
    <li>{{ item.name }} — {{ item.price | prepend: "€" }}</li>
  {% endfor %}
</ul>

forloop object

Inside a loop, the forloop object exposes metadata about the current iteration.

forloop.index1-based position
forloop.index00-based position
forloop.rindexRemaining items (1-based)
forloop.rindex0Remaining items (0-based)
forloop.firsttrue on first iteration
forloop.lasttrue on last iteration
forloop.lengthTotal number of iterations
{% for item in items %}
  {% if forloop.first %}<ul>{% endif %}
  <li>{{ forloop.index }}. {{ item.name }}</li>
  {% if forloop.last %}</ul>{% endif %}
{% endfor %}

limit and offset

{# First 3 items #}
{% for item in items limit: 3 %}
  {{ item.name }}
{% endfor %}

{# Items 4-6 (0-based offset) #}
{% for item in items limit: 3 offset: 3 %}
  {{ item.name }}
{% endfor %}

Reversed iteration

{% for item in items reversed %}
  {{ item.name }}
{% endfor %}

break and continue

{% for item in items %}
  {% if item.hidden %}{% continue %}{% endif %}
  {{ item.name }}
{% endfor %}

{% for item in items %}
  {% if forloop.index > 5 %}{% break %}{% endif %}
  {{ item.name }}
{% endfor %}

else (empty collection)

{% for item in items %}
  <li>{{ item.name }}</li>
{% else %}
  <li>No items found.</li>
{% endfor %}

Iterating ranges

{% for i in (1..5) %}
  <span>Step {{ i }}</span>
{% endfor %}

Assign & Capture

assign creates a variable. capture renders a block into a string variable. Both are scoped to the template.

assign

{% assign greeting = "Hello, " | append: user.name %}
{{ greeting }}

{% assign total = items | map: "price" | sum %}
Total: {{ total | prepend: "€" }}

{% assign isLarge = false %}
{% if total > 500 %}{% assign isLarge = true %}{% endif %}

capture

{% capture signature %}
  Best regards,<br>
  {{ sender.name }}<br>
  <a href="mailto:{{ sender.email }}">{{ sender.email }}</a>
{% endcapture %}

{# Use it anywhere below #}
{{ signature }}

Case / When

Use case / when as a switch statement. A when clause can match multiple values separated by commas. The optional else clause catches all unmatched cases.

{% case user.plan %}
  {% when "free" %}
    <p>Upgrade to unlock premium features.</p>
  {% when "pro", "business" %}
    <p>Thank you for being a Pro member.</p>
  {% when "enterprise" %}
    <p>Your dedicated account manager will be in touch.</p>
  {% else %}
    <p>Welcome!</p>
{% endcase %}

Built-in Filters

Filters transform a value using the pipe | operator. Multiple filters can be chained — they are applied left to right.

{{ "hello world" | capitalize | append: "!" }}
{# → Hello world! #}

{{ items | map: "price" | sum | round: 2 | prepend: "€" }}
{# → €138.90 #}

String filters

Filter
Input type
Example
Output
upcase
string
"hello" | upcase
"HELLO"
downcase
string
"HELLO" | downcase
"hello"
capitalize
string
"hello world" | capitalize
"Hello world"
append
string
"Hello" | append: " world"
"Hello world"
prepend
string
"world" | prepend: "Hello "
"Hello world"
strip
string
" hi " | strip
"hi"
lstrip
string
" hi " | lstrip
"hi "
rstrip
string
" hi " | rstrip
" hi"
strip_html
string
"<b>hi</b>" | strip_html
"hi"
strip_newlines
string
"a\nb" | strip_newlines
"ab"
newline_to_br
string
"a\nb" | newline_to_br
"a<br>b"
truncate
string
"Hello world" | truncate: 8
"Hello..."
truncatewords
string
"one two three" | truncatewords: 2
"one two..."
replace
string
"hi hi" | replace: "hi", "hey"
"hey hey"
replace_first
string
"hi hi" | replace_first: "hi", "hey"
"hey hi"
remove
string
"hello" | remove: "l"
"heo"
remove_first
string
"hello" | remove_first: "l"
"helo"
split
string
"a,b,c" | split: ","
["a","b","c"]
escape
string
"<b>" | escape
"&lt;b&gt;"
url_encode
string
"hello world" | url_encode
"hello+world"
url_decode
string
"hello+world" | url_decode
"hello world"
base64_encode
string
"hello" | base64_encode
"aGVsbG8="
base64_decode
string
"aGVsbG8=" | base64_decode
"hello"
sha256
string
"secret" | sha256
hex digest
md5
string
hex digest
size
string
"hello" | size
5

Number filters

Filter
Input type
Example
Output
abs
number
-5 | abs
5
ceil
number
4.2 | ceil
5
floor
number
4.9 | floor
4
round
number
4.567 | round: 2
4.57
plus
number
5 | plus: 3
8
minus
number
10 | minus: 3
7
times
number
5 | times: 3
15
divided_by
number
10 | divided_by: 3.0
3.33…
modulo
number
10 | modulo: 3
1
at_least
number
3 | at_least: 5
5
at_most
number
10 | at_most: 5
5

Array filters

Filter
Input type
Example
Output
first
array
items | first
items[0]
last
array
items | last
items[-1]
size
array
items | size
3
join
array
arr | join: ", "
"a, b, c"
reverse
array
[1,2,3] | reverse
[3,2,1]
sort
array
arr | sort: "price"
sorted
sort_natural
array
arr | sort_natural: "name"
case-insensitive sort
uniq
array
[1,2,1] | uniq
[1,2]
compact
array
[1,nil,2] | compact
[1,2]
map
array
items | map: "name"
["Shirt","…"]
where
array
items | where: "active", true
filtered array
sum
array
items | map: "price" | sum
138.9
concat
array
arr1 | concat: arr2
merged array
push
array
arr | push: "new"
array with new item
pop
array
arr | pop
array without last item

Date filters

The date filter formats a date string or timestamp. Use now or today as the input for the current time.

Filter
Input type
Example
Output
date
date / string
order.createdAt | date: "%d.%m.%Y"
"15.06.2025"
date
date / string
"now" | date: "%H:%M"
"14:30"
{# Common format codes #}
%Y  → 2025       (4-digit year)
%m  → 06         (2-digit month)
%d  → 15         (2-digit day)
%H  → 14         (hour, 24h)
%M  → 30         (minute)
%S  → 00         (second)
%A  → Sunday     (full weekday)
%B  → June       (full month name)

Miscellaneous

Filter
Input type
Example
Output
default
any
name | default: "Guest"
"Guest" if name is nil/empty/false
json
any
user | json
JSON string of object

Asset Filters

MailQ adds two custom filters for images uploaded to your tenant's asset library in the portal. Reference assets by their exact filename.

asset_url — public URL

Returns the full public URL of the asset. The image is loaded by the recipient's email client from the MailQ image CDN. Requires external image loading to be enabled (the default for most clients).

<img src="{{ 'logo.png' | asset_url }}" alt="Logo" width="200">
<img src="{{ 'banner.jpg' | asset_url }}" alt="Banner">

asset_inline — embedded MIME attachment

Embeds the asset directly in the email as an inline MIME attachment. The filter outputs a cid: reference and the image is attached automatically — no external image loading is required. This guarantees the image is visible even when clients block remote images.

<img src="{{ 'logo.png' | asset_inline }}" alt="Logo" width="200">
ⓘ

Using the same filename multiple times in one template reuses the same attachment — the image is only embedded once. Inline attachments increase email size; use them for logos and brand images, not large photos.

Comparison

asset_url
asset_inline
Output
https://img.mailq.cloud/…
cid:abc123@mailq
Image loading
External (CDN)
Embedded in email
Works offline
No
Yes
Email size impact
None
Adds image bytes
Best for
Large images, photos
Logos, signatures

Unknown filename

If the filename does not match any uploaded asset, the filter returns an empty string. Filenames are case-sensitive and must include the extension (e.g. logo.png, not logo).

Partials

Partials are reusable template snippets — headers, footers, legal notices, signatures — managed in the portal. Include them in any template with {% include 'slug' %}. The partial matching the send language is resolved automatically at render time.

{% include 'email-header' %}

<p>Dear {{ user.name }},</p>
<p>Your order <strong>{{ order.number }}</strong> has shipped.</p>

{% include 'order-items-table' %}
{% include 'email-footer' %}

Language resolution

If you send with language: "de", MailQ looks for a partial named email-header in the de locale. Missing partials render as empty — the email is never blocked by an unresolved include.

ⓘ

Partials cannot be sent directly via the API — they can only be included from a non-partial template. Create them in the portal with the Partial flag enabled.

Nesting

Partials can include other partials. Circular references are not detected and will cause a render timeout — keep nesting shallow.

Comments

Anything inside a comment block is stripped from the output. Use it to annotate templates or temporarily disable content.

{% comment %}
  TODO: replace placeholder image once brand assets are finalised
{% endcomment %}

<p>Hello, {{ user.name }}!</p>

{% comment %} quick inline comment {% endcomment %}