Template Variables
Quick Access: Personalize emails at scale with dynamic variables, conditional content, and merge tags that automatically populate with contact data.
Overview
Template Variables enable you to create dynamic, personalized email content that adapts to each recipient. Use merge tags to insert contact information, conditional logic to show/hide content, and default values to handle missing data gracefully.
Key Capabilities
-
Merge Tags: Insert contact data with
{{variable_name}} -
Default Values: Fallback content when data is missing
-
Conditional Blocks: Show/hide content based on conditions
-
Custom Variables: Workspace-specific variables
-
Dynamic Content: Personalize images, links, and text
-
Testing & Preview: Test with sample data before sending
-
Nested Variables: Complex data structures
-
Date Formatting: Display dates in any format
Level 1: Quick Start Guide
Basic Merge Tags
Standard Contact Fields
Hi {{first_name}},
Welcome to {{company}}! We're excited to have you on board.
Your account email is: {{email}}
Best regards,
The {{workspace_name}} Team
Output Example:
Hi John,
Welcome to Acme Inc! We're excited to have you on board.
Your account email is: john@acme.com
Best regards,
The PenguinMails Team
Available Standard Variables
Contact Information:
{{first_name}} # John
{{last_name}} # Doe
{{full_name}} # John Doe
{{email}} # john@example.com
{{phone}} # +1-555-123-4567
{{company}} # Acme Inc
{{job_title}} # Marketing Director
Location:
{{city}} # San Francisco
{{state}} # California
{{country}} # United States
{{timezone}} # America/Los_Angeles
Account:
{{lead_score}} # 87
{{created_at}} # 2025-10-15
{{last_activity}} # 2025-11-24
Workspace:
{{workspace_name}} # PenguinMails
{{workspace_url}} # https://app.penguinmails.com
{{sender_name}} # Sarah Johnson
{{sender_email}} # sarah@penguinmails.com
Default Values
Basic Syntax
Hi {{first_name | default: "there"}},
Welcome to {{company | default: "our platform"}}!
When Data Exists:
Hi John,
Welcome to Acme Inc!
When Data Missing:
Hi there,
Welcome to our platform!
Multiple Fallbacks
{{first_name | default: {{contact_name}} | default: "Valued Customer"}}
Fallback Chain:
-
Try
first_name -
If empty, try
contact_name -
If empty, use “Valued Customer”
Conditional Content
Simple If/Else
{% if lead_score >= 75 %}
You're a VIP customer! Here's an exclusive offer...
{% else %}
Check out our latest features...
{% endif %}
If/Elsif/Else
{% if lead_score >= 90 %}
🌟 Platinum Tier - Exclusive Access
{% elsif lead_score >= 75 %}
â Gold Tier - Premium Benefits
{% elsif lead_score >= 50 %}
✨ Silver Tier - Great Perks
{% else %}
👋 Welcome - Getting Started
{% endif %}
Checking for Empty Values
{% if company %}
We noticed you work at {{company}}.
{% else %}
Tell us about your company!
{% endif %}
Quick Examples
Personalized Greeting
{% if first_name %}
Hi {{first_name}},
{% else %}
Hello,
{% endif %}
Thanks for being a valued customer!
Location-Based Content
{% if country == "United States" %}
Free shipping on orders over $50!
{% elsif country == "Canada" %}
Free shipping on orders over $75 CAD!
{% else %}
International shipping available!
{% endif %}
Account Type Messaging
{% if custom.account_type == "trial" %}
Your trial expires in {{custom.trial_days_remaining}} days.
[Upgrade Now →]
{% elsif custom.account_type == "paid" %}
Thanks for being a premium customer!
{% else %}
[Start Your Free Trial →]
{% endif %}
Level 2: Advanced Variable Usage
Custom Fields
Accessing Custom Fields
Standard Syntax:
{{custom.industry}}
{{custom.company_size}}
{{custom.annual_revenue}}
Nested Custom Fields:
{{custom.preferences.email_frequency}}
{{custom.settings.notifications_enabled}}
Custom Field Examples
Industry-Specific Content:
{% if custom.industry == "SaaS" %}
Discover how {{company}} can scale with our API...
{% elsif custom.industry == "E-commerce" %}
Boost your online sales with our platform...
{% endif %}
Company Size Targeting:
{% if custom.company_size >= 200 %}
Enterprise solutions for large teams
{% elsif custom.company_size >= 50 %}
Team plans for growing businesses
{% else %}
Starter plans for small teams
{% endif %}
Advanced Conditionals
Multiple Conditions (AND)
{% if lead_score >= 75 and custom.account_type == "trial" %}
You're a highly engaged trial user!
[Upgrade to unlock premium features →]
{% endif %}
Multiple Conditions (OR)
{% if country == "United States" or country == "Canada" %}
North American customers get free shipping!
{% endif %}
Complex Logic
{% if (lead_score >= 75 and custom.account_type == "trial") or custom.is_vip == true %}
🎁 Special offer just for you!
{% if custom.account_type == "trial" %}
Upgrade now and get 20% off your first year!
{% else %}
Exclusive VIP discount: 15% off your next renewal!
{% endif %}
{% endif %}
Comparison Operators
Equals:
{% if status == "active" %}
Not Equals:
{% if status != "cancelled" %}
Greater Than:
{% if lead_score > 50 %}
Greater Than or Equal:
{% if lead_score >= 75 %}
Less Than:
{% if days_inactive < 30 %}
Less Than or Equal:
{% if age <= 65 %}
Contains:
{% if tags contains "vip" %}
Not Contains:
{% if tags not_contains "unsubscribed" %}
Dynamic Links
Personalized URLs
Track your order:
<a href="https://example.com/orders/{{order_id}}?email={{email}}">
View Order #{{order_id}}
</a>
Personalized landing page:
<a href="https://example.com/welcome?name={{first_name}}&ref={{campaign_id}}">
Get Started
</a>
UTM Parameters
<a href="{{landing_page_url}}?utm_source=email&utm_medium=campaign&utm_campaign={{campaign_name}}&utm_content={{email}}">
Click Here
</a>
Output:
https://example.com/offer?utm_source=email&utm_medium=campaign&utm_campaign=welcome_series&utm_content=john@example.com
Dynamic Images
Personalized Images
<img src="https://api.example.com/badge/{{first_name}}/{{lead_score}}"
alt="Your Score: {{lead_score}}">
<img src="{{cdn_url}}/images/{{custom.industry}}-hero.jpg"
alt="{{custom.industry}} Solutions">
Conditional Images
{% if lead_score >= 90 %}
<img src="{{cdn_url}}/badges/platinum.png" alt="Platinum Member">
{% elsif lead_score >= 75 %}
<img src="{{cdn_url}}/badges/gold.png" alt="Gold Member">
{% elsif lead_score >= 50 %}
<img src="{{cdn_url}}/badges/silver.png" alt="Silver Member">
{% endif %}
Date & Time Formatting
Date Filters
Account created:
{{created_at | date: "MMMM DD, YYYY"}}
Output: November 24, 2025
Last activity:
{{last_activity | date: "MMM D, YYYY"}}
Output: Nov 24, 2025
Short date:
{{created_at | date: "MM/DD/YY"}}
Output: 11/24/25
Relative time:
{{last_activity | time_ago}}
Output: 2 hours ago
Date Calculations
Trial expiration:
{% assign trial_end = created_at | date_add: 14, "days" %}
Your trial expires on {{trial_end | date: "MMMM DD, YYYY"}}
Days remaining:
{% assign days_left = trial_end_date | days_from_now %}
{{days_left}} days left in your trial
Loops & Iterations
Loop Through Lists
Your recent purchases:
{% for item in recent_purchases %}
- {{item.name}} - ${{item.price}}
{% endfor %}
Output:
Your recent purchases:
- Widget Pro - $49.99
- Gadget Plus - $29.99
- Tool Kit - $79.99
Loop with Conditionals
{% for tag in tags %}
{% if tag == "vip" %}
🌟 VIP Member
{% elsif tag == "enterprise" %}
🏢 Enterprise Customer
{% endif %}
{% endfor %}
Loop Limits
Top 3 products:
{% for product in recommended_products limit: 3 %}
{{forloop.index}}. {{product.name}} - ${{product.price}}
{% endfor %}
String Manipulation
Text Filters
Uppercase:
{{company | upcase}}
Output: ACME INC
Lowercase:
{{email | downcase}}
Output: john@example.com
Capitalize:
{{first_name | capitalize}}
Output: John
Title Case:
{{company | titlecase}}
Output: Acme Inc
Truncate:
{{description | truncate: 50}}
Output: This is a long description that will be...
Strip HTML:
{{bio | strip_html}}
Removes all HTML tags
String Operations
Concatenation:
{{first_name}} {{last_name}}
Output: John Doe
Replace:
{{phone | replace: "-", " "}}
Input: 555-123-4567
Output: 555 123 4567
Split:
{% assign name_parts = full_name | split: " " %}
First: {{name_parts[0]}}
Last: {{name_parts[1]}}
Number Formatting
Number Filters
Currency:
{{revenue | currency}}
Output: $1,234.56
{{revenue | currency: "EUR"}}
Output: €1,234.56
Percentage:
{{conversion_rate | percentage}}
Output: 12.5%
Round:
{{lead_score | round}}
Output: 87
Thousands separator:
{{subscribers | number_with_delimiter}}
Output: 1,234,567
Math Operations
Addition:
{% assign total = price + tax %}
Total: ${{total}}
Subtraction:
{% assign discount_price = price - discount %}
Sale Price: ${{discount_price}}
Multiplication:
{% assign total = quantity * price %}
Total: ${{total}}
Division:
{% assign monthly = annual_price | divided_by: 12 %}
Monthly: ${{monthly}}
Level 3: Technical Implementation
Database Schema
-- Template variables (workspace-level)
CREATE TABLE template_variables (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
workspace_id UUID REFERENCES workspaces(id),
-- Variable definition
variable_key VARCHAR(255) NOT NULL,
variable_name VARCHAR(255),
description TEXT,
-- Value
default_value TEXT,
variable_type VARCHAR(50), -- string, number, boolean, date, url
-- Validation
is_required BOOLEAN DEFAULT FALSE,
validation_rules JSONB,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(tenant_id, workspace_id, variable_key)
);
CREATE INDEX idx_template_variables_tenant ON template_variables(tenant_id);
CREATE INDEX idx_template_variables_workspace ON template_variables(workspace_id);
-- Variable usage tracking
CREATE TABLE variable_usage (
id UUID PRIMARY KEY,
template_id UUID REFERENCES templates(id),
variable_key VARCHAR(255),
usage_count INTEGER DEFAULT 0,
last_used_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_variable_usage_template ON variable_usage(template_id);
Variable Parser Service
interface VariableContext {
contact: Contact;
workspace: Workspace;
campaign?: Campaign;
customVariables?: Record<string, any>;
}
interface ParseOptions {
strictMode?: boolean; // Throw error on missing variables
escapeHtml?: boolean;
trackUsage?: boolean;
}
class VariableParser {
async parseTemplate(
templateContent: string,
context: VariableContext,
options: ParseOptions = {}
): Promise<string> {
let parsed = templateContent;
// Parse merge tags
parsed = await this.parseMergeTags(parsed, context, options);
// Parse conditional blocks
parsed = await this.parseConditionals(parsed, context);
// Parse loops
parsed = await this.parseLoops(parsed, context);
// Escape HTML if needed
if (options.escapeHtml) {
parsed = this.escapeHtml(parsed);
}
return parsed;
}
private async parseMergeTags(
content: string,
context: VariableContext,
options: ParseOptions
): Promise<string> {
// Match {{variable_name}} or {{variable_name | filter: "arg"}}
const mergeTagRegex = /\{\{([^}]+)\}\}/g;
return content.replace(mergeTagRegex, (match, variable) => {
const parts = variable.split('|').map(p => p.trim());
const variableName = parts[0];
const filters = parts.slice(1);
// Get variable value
let value = this.getVariableValue(variableName, context);
// Apply filters
for (const filter of filters) {
value = this.applyFilter(value, filter);
}
// Handle missing values
if (value === null || value === undefined) {
if (options.strictMode) {
throw new Error(`Variable not found: ${variableName}`);
}
return '';
}
// Track usage
if (options.trackUsage) {
this.trackVariableUsage(variableName);
}
return String(value);
});
}
private getVariableValue(
variableName: string,
context: VariableContext
): any {
// Handle nested properties (e.g., "custom.industry")
const parts = variableName.split('.');
let value: any = context;
for (const part of parts) {
if (value && typeof value === 'object') {
value = value[part];
} else {
return null;
}
}
return value;
}
private applyFilter(value: any, filter: string): any {
const [filterName, ...args] = filter.split(':').map(s => s.trim());
switch (filterName) {
case 'default':
return value || args[0]?.replace(/['"]/g, '');
case 'upcase':
return String(value).toUpperCase();
case 'downcase':
return String(value).toLowerCase();
case 'capitalize':
return String(value).charAt(0).toUpperCase() + String(value).slice(1);
case 'titlecase':
return String(value).replace(/\w\S*/g, (txt) =>
txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()
);
case 'truncate':
const length = parseInt(args[0]) || 50;
return String(value).length > length
? String(value).substring(0, length) + '...'
: value;
case 'date':
const format = args[0]?.replace(/['"]/g, '') || 'YYYY-MM-DD';
return this.formatDate(value, format);
case 'currency':
const currency = args[0]?.replace(/['"]/g, '') || 'USD';
return this.formatCurrency(value, currency);
case 'percentage':
return `${(parseFloat(value) * 100).toFixed(1)}%`;
case 'round':
return Math.round(parseFloat(value));
case 'number_with_delimiter':
return parseFloat(value).toLocaleString();
default:
return value;
}
}
private async parseConditionals(
content: string,
context: VariableContext
): Promise<string> {
// Match {% if condition %} ... {% endif %}
const conditionalRegex = /\{% if (.+?) %\}([\s\S]*?)\{% endif %\}/g;
return content.replace(conditionalRegex, (match, condition, body) => {
// Parse elsif and else
const parts = body.split(/\{% elsif (.+?) %\}|\{% else %\}/);
// Evaluate main condition
if (this.evaluateCondition(condition, context)) {
return parts[0];
}
// Check elsif conditions
for (let i = 1; i < parts.length; i += 2) {
if (parts[i] && this.evaluateCondition(parts[i], context)) {
return parts[i + 1] || '';
}
}
// Return else block if exists
return parts[parts.length - 1] || '';
});
}
private evaluateCondition(
condition: string,
context: VariableContext
): boolean {
// Parse condition (e.g., "lead_score >= 75")
const operators = ['>=', '<=', '==', '!=', '>', '<', 'contains', 'not_contains'];
for (const op of operators) {
if (condition.includes(op)) {
const [left, right] = condition.split(op).map(s => s.trim());
const leftValue = this.getVariableValue(left, context);
const rightValue = right.replace(/['"]/g, '');
switch (op) {
case '==':
return leftValue == rightValue;
case '!=':
return leftValue != rightValue;
case '>':
return parseFloat(leftValue) > parseFloat(rightValue);
case '<':
return parseFloat(leftValue) < parseFloat(rightValue);
case '>=':
return parseFloat(leftValue) >= parseFloat(rightValue);
case '<=':
return parseFloat(leftValue) <= parseFloat(rightValue);
case 'contains':
return String(leftValue).includes(rightValue);
case 'not_contains':
return !String(leftValue).includes(rightValue);
}
}
}
// Simple truthy check
return !!this.getVariableValue(condition, context);
}
private async parseLoops(
content: string,
context: VariableContext
): Promise<string> {
// Match {% if (.+?) %\}([\s\S]*?)\{% endif %\}/g;
return content.replace(conditionalRegex, (match, condition, body) => {
// Parse elsif and else
const parts = body.split(/\{% elsif (.+?) %\}|\{% else %\}/);
// Evaluate main condition
if (this.evaluateCondition(condition, context)) {
return parts[0];
}
// Check elsif conditions
for (let i = 1; i < parts.length; i += 2) {
if (parts[i] && this.evaluateCondition(parts[i], context)) {
return parts[i + 1] || '';
}
}
// Return else block if exists
return parts[parts.length - 1] || '';
});
}
private evaluateCondition(
condition: string,
context: VariableContext
): boolean {
// Parse condition (e.g., "lead_score >= 75")
const operators = ['>=', '<=', '==', '!=', '>', '<', 'contains', 'not_contains'];
for (const op of operators) {
if (condition.includes(op)) {
const [left, right] = condition.split(op).map(s => s.trim());
const leftValue = this.getVariableValue(left, context);
const rightValue = right.replace(/['"]/g, '');
switch (op) {
case '==':
return leftValue == rightValue;
case '!=':
return leftValue != rightValue;
case '>':
return parseFloat(leftValue) > parseFloat(rightValue);
case '<':
return parseFloat(leftValue) < parseFloat(rightValue);
case '>=':
return parseFloat(leftValue) >= parseFloat(rightValue);
case '<=':
return parseFloat(leftValue) <= parseFloat(rightValue);
case 'contains':
return String(leftValue).includes(rightValue);
case 'not_contains':
return !String(leftValue).includes(rightValue);
}
}
}
// Simple truthy check
return !!this.getVariableValue(condition, context);
}
private async parseLoops(
content: string,
context: VariableContext
): Promise<string> {
// Match {% for item in items %} ... {% endfor %}
const loopRegex = /\{% for (\w+) in (\w+)(?: limit: (\d+))? %\}([\s\S]*?)\{% endfor %\}/g;
return content.replace(loopRegex, (match, itemVar, arrayVar, limit, body) => {
const array = this.getVariableValue(arrayVar, context);
if (!Array.isArray(array)) {
return '';
}
const limitNum = limit ? parseInt(limit) : array.length;
const items = array.slice(0, limitNum);
return items.map((item, index) => {
const loopContext = {
...context,
[itemVar]: item,
forloop: {
index: index + 1,
index0: index,
first: index === 0,
last: index === items.length - 1,
length: items.length,
},
};
return this.parseMergeTags(body, loopContext, {});
}).join('');
});
}
private formatDate(value: any, format: string): string {
const date = new Date(value);
// Simple date formatting (use library like date-fns in production)
const formats: Record<string, string> = {
'YYYY-MM-DD': date.toISOString().split('T')[0],
'MMMM DD, YYYY': date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
}),
'MMM D, YYYY': date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
}),
'MM/DD/YY': date.toLocaleDateString('en-US', {
year: '2-digit',
month: '2-digit',
day: '2-digit'
}),
};
return formats[format] || date.toISOString();
}
private formatCurrency(value: any, currency: string): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(parseFloat(value));
}
private escapeHtml(content: string): string {
return content
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
private trackVariableUsage(variableName: string): void {
// Track which variables are being used
// Useful for analytics and cleanup
}
}
API Endpoints
// Parse template with test data
app.post('/api/templates/:id/parse', authenticate, async (req, res) => {
const { testData } = req.body;
const template = await db.templates.findById(req.params.id);
const parser = new VariableParser();
const context: VariableContext = {
contact: testData.contact || {},
workspace: await db.workspaces.findById(template.workspaceId),
customVariables: testData.customVariables || {},
};
const parsed = await parser.parseTemplate(
template.htmlContent,
context,
{ escapeHtml: false }
);
return res.json({ parsed });
});
// Get available variables
app.get('/api/templates/variables', authenticate, async (req, res) => {
const standardVariables = [
{ key: 'first_name', name: 'First Name', type: 'string' },
{ key: 'last_name', name: 'Last Name', type: 'string' },
{ key: 'email', name: 'Email', type: 'string' },
{ key: 'company', name: 'Company', type: 'string' },
{ key: 'lead_score', name: 'Lead Score', type: 'number' },
// ... more standard variables
];
const customVariables = await db.templateVariables.findAll({
where: { tenantId: req.user.tenantId },
});
return res.json({
standard: standardVariables,
custom: customVariables,
});
});
// Create custom variable
app.post('/api/templates/variables', authenticate, async (req, res) => {
const { variableKey, variableName, defaultValue, variableType } = req.body;
const variable = await db.templateVariables.create({
tenantId: req.user.tenantId,
workspaceId: req.body.workspaceId,
variableKey,
variableName,
defaultValue,
variableType,
});
return res.json(variable);
});
Related Documentation
-
Template Editor - Visual template creation
-
Template Management - Template organization
-
Personalization System - Advanced personalization
-
Leads Management - Contact data source
Last Updated: November 25, 2025 Status: Planned - High Priority (Level 2) Target Release: Q2 2026 Owner: Templates Team