Personalization & Expressions

Merge fields, conditionals, fallbacks, functions, spintax, and automation expressions: the templating language shared by campaign emails and automations.

Personalization lets you write one email that comes out tailored for every contact, using merge fields like {{.FirstName}}. The same language also powers automation conditions and action text, and needs no programming knowledge.

Personalize an email

In a campaign step's subject or body, type a merge field such as {{.FirstName}}.

Give fields that may be empty a fallback: {{.FirstName | default "there"}}.

Open the Preview to check it renders as expected, with no leftover {{...}} tokens.

Good to know

  • Field names are case-sensitive: {{.FirstName}} works, {{.firstname}} does not.
  • Unknown variables render empty rather than erroring.
  • Every {{if}} must close with {{end}}. A broken one blocks launching the campaign.
  • Spintax such as {Hi|Hey|Hello} varies wording in campaign subjects and bodies only.
  • Automations use the trigger's lowercase keys, such as {{.contact_email}}, still with the leading dot.

Write one email and have it come out personalized for every contact, using merge fields like {{.FirstName}} plus helpers for fallbacks, conditions, and math. No programming knowledge needed; match the patterns below.

You want toWrite
Insert a first name{{.FirstName}}
Insert the sending mailbox's name{{.Sender.Name}}
Insert the actual From address{{.Sender.Email}}
Back up an empty field{{.FirstName | default "there"}}
Branch on a condition{{if eq .Company "Acme"}}...{{else}}...{{end}}
Randomize wording (campaigns only){a|b|c}
Filter an automation by a numbergtf .confidence 0.8

Contact fields (campaigns)

Exact names, capitalized, with a leading dot: {{.FirstName}}, {{.LastName}}, {{.Email}}, {{.Company}}, {{.Phone}}.

Hi {{.FirstName}}, I noticed {{.Company}} is hiring and wanted to reach out.

Field names are case-sensitive

{{.FirstName}} works. {{.firstname}}, {{.firstName}}, and {{.FIRSTNAME}} do not, and leave an empty value or a leftover token in your email.

Custom fields work the same way with a dot: {{.industry}}, {{.account_owner}}. Names containing spaces or dashes also work as written ({{if .job title}}...{{end}}) and resolve everywhere, including inside conditions and helpers, because Zexr rewrites them to an index lookup for you.

Sender fields (emails)

Sender is a structured mailbox object. Use Go template field access, such as {{.Sender.Name}}, in the subject, HTML body or plain-text body:

Hi {{.FirstName}}, I'm {{.Sender.Name}} from our team.
You can reach me at {{.Sender.Email}}.

Values come from the mailbox actually sending that message, including campaign sender rotation and A/B variants. Campaign test emails, placement tests and server previews use their selected mailbox. The campaign preview's From line uses the same identity. A local sample preview uses example data, not a live mailbox.

{{.Sender.Email}} is the recipient-visible From address, including a configured send-as alias. {{.Sender.MailboxEmail}} is the underlying connected mailbox address. {{.Sender.ReplyTo}} is the explicit Reply-To header; it is empty when replies simply go to the sender.

Every field is case-sensitive. Prefix each name below with .Sender., for example {{.Sender.Provider}}:

DetailsFields
IdentityName, Email, MailboxEmail, SendAsEmail, ReplyTo, AvatarURL
SignatureSignaturePlain, SignatureHTML, SignatureSync, SignatureCode
ConnectionProvider, Status, MailHost, AuthMethod, Vendor
Sending settingsTimezone, CampaignLimit, MinWaitTime, SaveToSent, RelayFolderMoves, Tags
TrackingTrackingDomain, TrackingDomainVerified, TrackingDomainVerifiedAt, TrackDirectMail
Domain authenticationAuthState, AuthSPF, AuthDKIM, AuthDMARC, AuthDMARCPolicy, AuthReason, AuthCheckedAt, AuthFailingSince
WarmupWarmup, WarmupPausedAt, WarmupBase, WarmupMax, WarmupIncrease, WarmupReplyRate, WarmupTag, WarmupPoolType, WarmupStartTime, WarmupEndTime, WarmupDays, WarmupPlacement, WarmupFolder, WarmupRetentionDays
LifecycleLastSyncedAt, CreatedAt, UpdatedAt

Limits, gaps, volumes, reply rate, days and retention are numbers. MinWaitTime is in seconds, WarmupDays is the configured sending-days bitmask, and WarmupRetentionDays is in days (zero uses the instance default). Flags such as SignatureSync, SaveToSent and AuthSPF are booleans, so use {{if .Sender.SignatureSync}}...{{end}}. AuthDKIM false means unverified, not that a DKIM record is missing. Timezone falls back to the workspace timezone; an empty timezone means UTC. Warmup/tracking settings are the stored mailbox settings, not inferred campaign overrides.

Timestamps, including Warmup (when warmup was enabled), render as UTC RFC 3339 strings such as 2026-10-05T15:00:00Z, or empty when unset. Tags is a list and supports native iteration:

{{range .Sender.Tags}}{{.}} {{end}}
{{.Sender.Name | default "our team"}}

Without a selected mailbox, sender strings are empty, flags are false, numbers are zero and tags are empty. These fields are available in campaign and placement email rendering, not automation trigger data, deal names or inbox reply-template rendering. Unknown sender fields remain unresolved so previews can flag them. Sender is reserved and cannot be replaced by a contact custom field.

Safe mailbox details only

Credentials, OAuth tokens, passwords, internal IDs, worker routing and sync cursors are never exposed. The object has no mailbox methods. If you insert SignatureHTML or SignaturePlain yourself, disable automatic signatures for that mailbox to avoid a duplicate sign-off.

Trigger variables (automations)

Automations carry the trigger's data rather than a contact's saved fields, keyed in lowercase and referenced with the required leading dot:

New reply from {{.contact_email}} on campaign {{.campaign_id}} ({{.intent}})
TriggerVariables
Reply receivedcontact_email, contact_id, campaign_id, intent, confidence, subject, snippet, and with inbox tagging on: ai_labels, ai_intent, ai_intent_confidence, ai_kind, ai_relevance, ai_needs_review
Contact createdcontact_email, contact_id, first_name, last_name, company, phone, subscribed, source, source_detail, custom_fields (a map: {{.custom_fields.industry}}), campaign_ids, category_ids
Form submittedcontact_email, contact_id, first_name, last_name, company, phone, form_id, form_name, submission_id, source_url, campaign_id, data (every answer by its key: {{.data.team_size}})
Meeting booked / rescheduledinvitee_name, invitee_email, event_name, scheduled_for, join_url, source, contact_id
Meeting canceledSame, without join_url
Email bounced, Deliverability complaintcontact_email, campaign_id, contact_id, event_type, provider, reason
Unsubscribedcontact_email, contact_id, campaign_id, source
Warmup health changedemail, new_state, previous_state, reason
Campaign actioncontact_email, contact_id, campaign_id, campaign_name, first_name, last_name, company, phone

Action text is a full template, not token substitution: conditionals, pipelines, helpers, {{range}}, and nested fields ({{.lead.company}}) all work, and values keep their type. Because numbers stay numbers, {{if gt .confidence 0.8}} works directly in a message body. A malformed template never blocks the action; it falls back to plain {{.key}} substitution. Unknown keys render empty. {{range}} loops over a field of the data ({{range .lead.tags}}), never a number or a function's result, and nests at most two deep; {{template}} and {{block}} are not supported, and output is capped at 1 MB. A template past any of these limits falls back the same way.

Put the field first, then a pipe, then default and your backup in quotes:

Hi {{.FirstName | default "there"}},
Hope things are going well at {{.Company | default "your company"}}.

{{if}} includes text only when something is true, {{else}} handles the other case, and every {{if}} must close with {{end}}.

{{if .Company}}I see you're at {{.Company}}.{{end}}
{{if eq .Company "Acme"}}Great to connect with the Acme team.{{else}}Great to connect.{{end}}

eq is equals and ne is not-equals. Quote your text values.

The same helpers work in campaign emails, automation conditions, and action text.

MathMeaningStringWhat it does
add sub mul div modArithmeticlower upper titleChange case
num aForce a value to a numbertrimRemove surrounding spaces
containsContains, case-insensitive
hasPrefix hasSuffixStarts or ends with, case-sensitive
has list itemThe list holds that whole item, case-insensitive
join list sepThe list as one line of text

Division or modulo by zero returns 0 rather than erroring, so these never break an email.

Comparing numbers

  • Native: gt, lt, ge, le, eq, ne compare as-is, and work when the value is already a number (typical of automation data like confidence).
  • Coercing: gtf, ltf, gef, lef read both sides as numbers first. Use these when a value might be text (typical of contact custom fields).

Rule of thumb

Comparing automation numbers? Native gt / ge works. Comparing a custom field or anything that might be text? Use the f versions so "42" reads as 42. When in doubt, the f versions are safe.

{{if gtf .deal_size 10000}}Enterprise prospect{{end}}
{{if hasSuffix .Email ".edu"}}As someone in academia, {{end}}I thought this might help.

Alternatives in single braces separated by pipes, so a batch of sends does not look identical:

{Hi|Hey|Hello} {{.FirstName}}, {hope you're well|hope things are good}.

Spintax rules

Campaign subject and body only; it does not run in automations. Use single braces, not the double braces of merge fields. Only groups containing a pipe count, so a brace run without one (CSS or JSON in an HTML email) is left exactly as written.

For anything the visual picker cannot express. An expression passes when it renders to something truthy; it is false when empty, false, 0, no, off, or <no value>, and a broken expression is false rather than silently passing.

Write it bare (recommended) or as a full template when you need {{else}}:

gt .confidence 0.8
{{if gt .confidence 0.8}}yes{{end}}
and (gtf .confidence 0.8) (eq .intent "positive")
or (eq .intent "positive") (eq .intent "neutral")
not (eq .intent "negative")
hasSuffix .contact_email "@acme.com"

and, or, and not take whole tests as inputs, so wrap each in parentheses when mixing groups.

SurfaceVariablesConditionals / functionsdefaultSpintax
Campaign subject & bodyContact fieldsYesYesYes
Automation conditionTrigger keysYesYesNo
Automation action textTrigger keys, with the dotYesYesNo
  • Standard fields are case-sensitive: {{.FirstName}}, not {{.firstname}}.
  • {a|b} is spintax (campaigns only); {{...}} is a variable, condition, or function.
  • Always close {{if}} with {{end}}, and quote text values.
  • If a comparison on a custom field behaves oddly, switch gt/lt to gtf/ltf, or wrap the value in num.
  • Unknown variables render empty rather than erroring. A leftover {{...}} usually means a typo or wrong capitalization.
  • In action text keep the leading dot. It is standard Go text/template, so a bare {{contact_email}} is invalid.

Campaigns: the composer's live Preview renders against a sample contact exactly as the real send does (variables, conditionals, functions, then spintax). It flags errors like a broken {{if}}, which block launching, and unresolved tokens left after rendering.

Automations: an advanced expression is validated on save, so a condition that does not parse cannot be saved. The Reference button lists every variable and function inline.

On this page