Skip to content

Data and expressions ​

Most text settings of a node can contain expressions written between double braces: {{ email.from.email }}, {{ data.categorize.category }}. Just before the node runs, Mankomail replaces each expression with a value read from the run, then hands the finished text to the node.

The language is deliberately small. An expression is a path to a value, optionally followed by filters. There are no function calls, no arithmetic, no conditions and no loops. The same engine computes the preview in the editor and the real value during a run, so what you see while editing is what the node receives.

Syntax at a glance ​

{{ path }}
{{ path | filter }}
{{ path | filter | filter:"argument" }}
ElementRuleExample
Delimiters{{ opens, }} closes. Spaces inside are optional.{{email.subject}} and {{ email.subject }} are identical.
PathSegments separated by dots. Each segment is a name ([A-Za-z_$][A-Za-z0-9_$]*) or an array index made of digits.email.to.0.email
FilterIntroduced by |. Filters apply from left to right.{{ email.subject | trim | upper }}
Filter argumentAfter a colon: "double quotes", 'single quotes' or a bare word.{{ data.extract.ref | default:"n/a" }}
Literal bracesWrite \{{ to output {{ without evaluating anything.\{{ not evaluated }} → {{ not evaluated }}

Text outside the braces is copied unchanged, so you can mix free text and several expressions in one field:

Hello {{ email.from.name | default:"there" }}, we received your request "{{ email.subject | trim }}".

What an expression can read ​

An expression reads from exactly two roots:

RootContents
emailThe triggering email of the run, in the canonical message format described below.
dataThe working data: the outputs of the steps that already finished, plus the input brought by a trigger that is not an email (webhook, schedule, call, integration).

Nothing else is reachable: no clock, no environment, no secrets, no other email of the mailbox. A path that starts with anything other than email. or data. resolves to nothing.

When the run has no triggering email (webhook, schedule, call from a workflow that has no email itself, integration event), email is empty: every {{ email.… }} renders as an empty string. Runs started from an email (email trigger, manual run on an email) and the sub-runs they start (loop iterations, called workflows) all see that email.

The triggering email (email) ​

Every email in Mankomail, whatever its provider (Gmail, Microsoft 365, IMAP), is presented in the same shape. All encodings are already decoded; dates are ISO 8601 strings in UTC.

PathTypeDescription
email.subjectstringDecoded subject. Empty string when the email has none — never missing.
email.fromaddress objectThe sender. Missing when the email has no usable From.
email.from.emailstringSender address, normalized (domain in lowercase).
email.from.namestringSender display name. Missing when the header carries no name.
email.tolist of addressesRecipients.
email.cclist of addressesCarbon-copy recipients.
email.bcclist of addressesBlind-copy recipients. Almost always empty on a received email.
email.replyTolist of addressesAddresses from the Reply-To header.
email.receivedAtstring (ISO 8601 UTC)Reception date recorded by the provider. The reliable date.
email.sentAtstring (ISO 8601 UTC)Date declared by the sender (Date header). Can be missing or wrong.
email.bodyTextstringPlain-text body, derived from the HTML when the email only has HTML. When the full original email is not stored, this is the beginning of the body only.
email.bodyHtmlstringHTML body, not sanitized. Missing for text-only emails.
email.attachmentslist of attachmentsAttachment metadata only, never the content.
email.folderLabelslist of stringsIMAP folders or Gmail labels carrying the email.
email.flagsobjectProvider state: seen, flagged, draft, sent.
email.signalsobjectDetections computed at reception: isAutoReply, isNoReply, isMailingList, isFromSelf.
email.headersobjectHeaders, names in lowercase, each value a list of strings.
email.messageIdHeaderstringMessage-ID header, angle brackets included.
email.inReplyTostringIn-Reply-To header, angle brackets included.
email.referenceslist of stringsReferences header, split into identifiers, oldest first.
email.providerIdstringIdentifier of the email at its provider.
email.bodyRefstringInternal reference to the stored original email. Not meant for templates.

A field marked as possibly missing renders as an empty string when absent. Add | default:"…" when an empty value would read badly.

Addresses, attachments, flags and signals ​

Address object (email.from, each item of email.to, email.cc, email.bcc, email.replyTo):

FieldTypeDescription
emailstringNormalized address: local@domain-in-lowercase.
namestringDecoded display name. Can be missing.

Lists are read by index, starting at 0: {{ email.to.0.email }} is the first recipient, {{ email.cc.1.name }} the name of the second Cc recipient. Writing the list itself ({{ email.to }}) renders it as JSON.

Attachment (each item of email.attachments):

FieldTypeDescription
filenamestringDecoded file name (a generated name when the email gives none).
mimestringMIME type in lowercase, without parameters (application/pdf).
sizenumberDecoded size in bytes.
dispositionstringattachment for a real attachment, inline for an image embedded in the HTML (logo, signature).
contentIdstringContent-ID without angle brackets, for inline images. Can be missing.
blobRefstringInternal reference to the stored content. Not meant for templates.

Flags (email.flags.*, booleans): seen (read), flagged, draft, sent (the email is in the sent folder).

Signals (email.signals.*, booleans): isAutoReply (out-of-office or auto-responder), isNoReply (unmonitored sender such as no-reply@), isMailingList (newsletter, mailing list, bulk sending), isFromSelf (sent by Mankomail itself).

Booleans render as true or false.

Email headers ​

email.headers maps each header name, in lowercase, to the list of its values. Read the first value with index 0:

{{ email.headers.subject.0 }}
{{ email.headers.received.0 }}

Header names with a hyphen

A path segment cannot contain -. Headers such as x-priority, list-id or content-type therefore cannot be reached by an expression: {{ email.headers.x-priority.0 }} is not a valid expression. Use the dedicated fields instead (email.signals.isMailingList, email.inReplyTo, email.messageIdHeader…) or a trigger condition.

Headers are read from the stored original email. When the original is not kept, email.headers is an empty object.

Working data (data) ​

Each node that finishes successfully publishes its output in the working data, under a key derived from the node name shown on the canvas. If a node named Categorize outputs { "category": "Invoice" }, the next nodes read it with:

{{ data.categorize.category }}

The output fields of each node are listed on its page in the node reference.

How the key is derived from the name:

  1. Accents are removed (Trié → Trie).
  2. Leading and trailing spaces are removed.
  3. Every run of characters other than letters, digits, _ and $ becomes a single _.
  4. Leading and trailing _ are removed.
  5. The result is put in lowercase.
  6. If the result is empty or starts with a digit, the key is the node identifier instead (see below).
Node nameKey
Categorizedata.categorize
Catégoriserdata.categoriser
Sort invoicesdata.sort_invoices
Extract (AI)data.extract_ai
Résumé — clientdata.resume_client
2nd checkthe node identifier (the name starts with a digit)

A new node is named after its type in your interface language, so a freshly added Categorize node is data.categorize in English and data.categoriser in French. Renaming a node changes its key: expressions that used the old name then render empty.

Alias by identifier. Each output is also published under the node identifier, which never changes when you rename the node. Nodes added in the editor get identifiers such as categorize_1, if_2, request_1 (the last part of the node type and a number). The editor inserts this form when you drag a value from the Input panel of a node: {{ data.categorize_1.category }}. Both forms point to the same value.

Two nodes with the same key. When two nodes produce the same key (Sort invoices and Sort, invoices), the first one to finish keeps the name-based key; read the other one through its identifier.

What is in data at a given step:

  • the outputs of the steps of this run that succeeded and published something. A node that failed, was skipped, or was disabled and passed through publishes nothing;
  • the trigger input (next section), placed first: a node named webhook cannot hide data.webhook.

Read only nodes that are upstream of the current node. The output of a parallel branch is present only if that branch happened to finish earlier, which is not guaranteed.

Data brought by the trigger ​

A trigger that does not bring an email puts its input at the root of data:

TriggerKeyContents
Webhook receiveddata.webhookThe JSON body of the request: {{ data.webhook.client.email }}.
Called by a workflowdata.inputWhat the calling workflow sent: {{ data.input.reference }}.
Scheduledata.scheduleplannedFor (the planned time, ISO 8601 UTC), firedAt (the actual time), timezone (UTC when none is set).
Integration event (webhook)data.<integration>The event body under the integration identifier, for example data.mynotary, data.yousign, data.notion.
Integration change (polling)data.<integration>The changed item, for example data.airtable or data.notion.

{{ data.schedule.plannedFor }} is the rounded planned time (…T08:00:00.000Z), which is what you usually want in a subject line, while firedAt is when the run actually started.

In test runs, the test body you write is placed under the same key, so the workflow reads data.webhook or data.input exactly as in production.

Inside a loop ​

The nodes connected to the for each output of a Loop run once per item, each time in a separate run. In that run, data additionally contains:

PathTypeDescription
data.itemanyThe current item. An array of N items when the batch size is greater than 1.
data.indexnumberPosition of the current item, starting at 0.
data.countnumberTotal number of items.
data.firstbooleantrue for the first item.
data.lastbooleantrue for the last item.

The same five values are also available under the loop node's key: {{ data.loop.item }} for a loop named Loop. In nested loops, data.item is the item of the innermost loop; reach the outer item through the outer loop's name, for example {{ data.rows.item.email }}.

Everything produced before the loop stays readable inside the body. If a node of the body has the same key as a node before the loop, the body node wins.

Example: a loop over {{ data.read.rows }} whose body sends one email per row:

To:      {{ data.item.email }}
Subject: Reminder {{ data.index }}/{{ data.count }} — {{ data.item.reference }}

data.index starts at 0. There is no arithmetic, so {{ data.index }} cannot be turned into a 1-based number.

Paths: rules and limits ​

  • Dots only. data.extract.order.number. Brackets and quotes are not supported: data["extract"] is not an expression.
  • Array indexes are digits. email.attachments.0.filename. Negative indexes do not exist. An index past the end of the list resolves to nothing.
  • Segment characters. A segment is a letter, _ or $, followed by letters, digits, _ or $, or it is made only of digits. A key that contains a hyphen, a space or an accented letter (for example a webhook field first-name, or a column named Client name) cannot be reached directly. Reference its parent object to get it as JSON, or rename the field at the source.
  • Own data only. A path only reads the data itself: {{ email.subject.length }} resolves to nothing, as do the reserved segments __proto__, prototype and constructor.
  • Case-sensitive. email.from.Email resolves to nothing.

Filters ​

Filters transform the text produced by the path. They are applied from left to right, each receiving the result of the previous one. The list is closed: these four filters are the only ones.

FilterArgumentEffectExample
defaultrequiredReplaces the value with the argument when the value is empty (missing path, null, or empty string). Also silences the missing-path warning.{{ email.from.name | default:"Sir or Madam" }}
uppernoneConverts to uppercase.{{ email.from.email | upper }} → ADA@EXAMPLE.COM
lowernoneConverts to lowercase.{{ email.from.name | lower }} → ada lovelace
trimnoneRemoves leading and trailing whitespace.{{ email.subject | trim }}

Chaining:

{{ email.subject | trim | upper }}                 →  DEVIS 1042   (subject "  Devis 1042  ")
{{ data.extract.ref | default:"none" | upper }}    →  NONE         (when ref is missing)

Writing the argument of default:

FormExampleResult when empty
Double quotesdefault:"n/a"n/a
Single quotesdefault:'?'?
Bare word (trimmed)default:00
Quote inside quotesdefault:"says \"no\""says "no"
Pipe inside quotesdefault:"a|b"a|b

Inside quotes, a backslash escapes ", ' and \. An argument cannot contain }}, which always closes the expression.

default only reacts to an empty value. An empty list renders as [] and is therefore not empty for default.

Mistakes:

You writeWhat happens
{{ email.from.name | capitalize }}Unknown filter: it is ignored, the value is kept, and a warning is raised.
{{ email.from.name | upper:1 }}Argument given to a filter that takes none: the filter is ignored, the value is kept, and a warning is raised.
{{ data.x | default }}default without an argument: the filter is ignored and a warning is raised.
{{ email.from.name | 42 }}Invalid filter name: ignored, warning raised.

All four are input errors: the editor flags the field and the workflow cannot be tested or published until they are fixed (see Validation while you type).

How values are rendered ​

An expression always produces text:

Value foundRendered as
StringAs is.
NumberDecimal notation: 1290.5, 0. A non-finite number renders empty.
Booleantrue or false.
nullEmpty string, without warning.
Object or listCompact JSON: {{ email.to }} → [{"name":"Ada","email":"ada@example.com"}], {{ email.folderLabels }} → ["INBOX","IMPORTANT"].
DateDates in the email and in node outputs are already ISO 8601 strings (2026-08-27T10:00:00.000Z) and render as such.

There is no date or number formatting filter. To get a formatted date or number, produce it in an upstream node and reference that node's output.

Rendering an object or a list as JSON is useful in an HTTP body or to pass a whole list to a Loop: the loop reads the JSON back as a list.

Missing values and malformed expressions ​

An expression never makes a run fail. Each anomaly has a fixed behavior:

SituationExampleOutputWarning code
Path does not exist{{ email.cc.0.email }} on an email without Ccempty stringmissing_path
Path exists, value is nulla node output "category": nullempty stringnone
Empty path or invalid path{{ }}, {{ email..subject }}, {{ data.my-node.x }}the expression is left as is, braces includedmalformed_expression
Function call or code{{ process.exit(1) }}left as ismalformed_expression
No closing bracesHello {{ email.from.namethe rest of the text is left as isunterminated_expression
Unknown filter{{ email.subject | capitalize }}value without the filterunknown_filter
Wrong filter argument{{ email.subject | upper:1 }}value without the filterinvalid_filter_argument

A missing path during a real run renders as an empty string and the step continues with that value. This is the normal case for optional fields: a mail without Cc must still be processed. Guard the fields that matter with default, or check them beforehand with a Condition (If).

Escaping {{ ​

To write literal double braces (in an HTML template, a JSON example, a message that explains the syntax), put a backslash before them:

Use \{{ email.subject }} in your template.

renders Use {{ email.subject }} in your template. The backslash is removed and nothing is evaluated. A single brace ({ … }) never needs escaping.

No code evaluation ​

Expressions are not JavaScript and are never executed as code. Only plain paths and the four filters exist. Consequences:

  • no arithmetic ({{ data.index + 1 }} is malformed and left as is);
  • no comparison or condition inside an expression: use a Condition (If) or a Switch node;
  • no function or method call;
  • no access to anything outside email and data.

This is a security property: the content of an email, which anyone can send you, can never execute code through an expression.

Which settings accept expressions ​

Expressions are evaluated only in the settings of nodes that run as steps, right before the node runs. Triggers and AI provider nodes never run as steps: their settings are read as written.

In a node, by default:

Kind of settingAccepts {{ }}
Single-line text and multi-line textYes, unless the node refuses it for that field (list below).
Key/value lists (for example HTTP headers)Yes for the values; never for the keys.
Comparison value of a condition (Condition (If), Switch)Yes.
Resource picker (Pick / Identifier / URL)Only in Identifier mode.
Number, yes/no, single choice, multiple choiceNo, unless the node explicitly opens the field to expressions.
Connection (secrets)Never.
Collections (repeated groups of fields)Each sub-field follows its own kind.

When a non-text field is opened to expressions, the editor shows a Fixed / Expression switch on it.

Settings that refuse expressions ​

Some text settings refuse {{ }} on purpose. Typing an expression there is flagged as an error ("This field does not accept “{{ }}” expressions."). The field stays fully editable: only expressions are refused.

Two families are concerned. Security: content coming from an email must not reach the instructions of a model, nor a secret URL. Structure: names that create the outputs of a node, or the keys of its data, must be known when the workflow is published, otherwise a branch could silently lead nowhere.

NodeSettings
CategorizeCategory Name, System prompt
ExtractField Identifier
Free instruction (AI)JSON schema
SwitchBranch name of each rule
TransformField name (the value accepts expressions)
HTTP requestField name of the form fields and of the files to upload
NotifyWebhook URL
Call a workflowWorkflow to call
Sheets — append a rowColumn header, Deduplication column
Table nodes (find, list, insert, update, insert or update, delete)Key column of the row key, Column of the values and of the filters, Sort by (list rows)
Table — append a commentColumn to append to

Triggers (Schedule: Cron expression, Time zone; Airtable: Additional filter; and the other trigger settings) and the Model of AI provider nodes are never rendered either: an expression there would be used literally.

Expressions in number and choice fields ​

Rendering always produces text. When a node opens a number or choice field to expressions, {{ data.extract.quantity }} arrives as the text "3", and the node converts it when it runs. At publication, only the syntax of the expression is checked: the value itself is known only during the run.

Validation while you type ​

Every expression is checked while you edit, with the same engine that runs it:

  • Blocking (the field is flagged, and the workflow can be neither tested nor published): unclosed {{, invalid path, unknown filter, missing or extra filter argument ("The “{{ }}” expression is malformed."), or an expression in a field that refuses them.
  • Not blocking: a path that does not exist yet. At edit time nothing has run, so the validator cannot know whether data.categorize.category will exist; only the preview can tell, on a real example.

Preview in the editor ​

Each text field that accepts expressions offers:

  • highlighting of the {{ }} parts;
  • autocompletion of the email fields, of the outputs of upstream nodes, and of the four filters;
  • a live Preview line showing the rendered text, or (empty);
  • the warnings of the expression, for example "Path “…” does not exist in the input data.".

The preview is computed on the active test email and the data of the last test of upstream nodes. When no test email is active, it is computed on a built-in sample email and says so ("Preview computed on a sample email."), with a Pick a test email button.

The Input panel of a node lists the triggering email and each upstream node with the data it produced at the last test. Drag a value onto a field, or click it to insert it into the last field you were typing in. Inserted paths use the node identifier ("Paths reference the node by its id: renaming it breaks nothing.").

Examples ​

GoalExpression
Greet the sender, with a fallbackHello {{ email.from.name | default:"there" }},
Reply subjectRe: {{ email.subject | trim }}
First attachment name{{ email.attachments.0.filename | default:"(no attachment)" }}
Date of reception{{ email.receivedAt }} → 2026-08-27T10:00:00.000Z
Category chosen by Categorize{{ data.categorize.category }}
Same, robust to renaming{{ data.categorize_1.category }}
Value extracted by Extract{{ data.extract.order_number | default:"unknown" }}
Field of a webhook body{{ data.webhook.customer.email }}
Planned time of a schedule{{ data.schedule.plannedFor }}
Current loop item{{ data.item.reference }}
A whole list, as JSON, for a loop{{ data.read.rows }}
JSON body of an HTTP request{"ref": "{{ data.extract.ref }}", "from": "{{ email.from.email }}"}
Literal braces\{{ email.subject }}

Building a JSON body

An expression inserts raw text: if the value contains a quote or a line break, the JSON becomes invalid. Insert values that you know are simple (identifiers, addresses), or pass a whole object, which is rendered as valid JSON: {"customer": {{ data.webhook.customer }}}.

  • Runs: where the working data comes from and how it is stored.
  • Test runs: running a draft on a real email to fill the preview with real data.
  • Triggers: what each trigger brings.
  • Node reference: the output fields of every node.