Mail Merge
Use the mailMerge array in the message submission API call to generate per-recipient copies of the same message, inject template variables, and keep each copy in the mailbox's Sent Mail folder.
Why It Matters
Bulk-sending receipts, onboarding tips or weekly digests from your customer's mailbox means better deliverability and brand consistency - but you don't want 500 addresses exposed in the To header. EmailEngine turns one REST call into N fully-formed messages, so every recipient feels like the only one.
How Mail Merge Works
Instead of calling the submit API multiple times, you:
- Drop
to/cc/bccfrom your payload - Add a
mailMergearray with per-recipient data - EmailEngine fans out the request into distinct messages
- Each message gets its own Message-ID for tracking
- Handlebars templates enable personalization
Basic Mail Merge
Broadcasting Same Content
Send the same message to multiple recipients without exposing addresses:
curl -XPOST "https://ee.example.com/v1/account/example/submit" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{
"subject": "Test message",
"html": "<p>Each recipient will get the same message</p>",
"mailMerge": [
{
"to": {
"name": "Ada Lovelace",
"address": "ada@example.com"
}
},
{
"to": {
"name": "Grace Hopper",
"address": "grace@example.com"
}
}
]
}'
Response:
{
"sendAt": "2025-05-14T09:12:23.123Z",
"mailMerge": [
{
"success": true,
"to": {
"name": "Ada Lovelace",
"address": "ada@example.com"
},
"messageId": "<91853631-2329-7f13-a4df-da377d873787@example.com>",
"queueId": "182080c50b63e7e232a"
},
{
"success": true,
"to": {
"name": "Grace Hopper",
"address": "grace@example.com"
},
"messageId": "<8b47f91c-06f3-b555-ee19-2c99908aff25@example.com>",
"queueId": "182080c50f283f49252"
}
]
}
Each recipient:
- Sees only their own address in the
Tofield - Receives a message with a unique Message-ID
- Gets their own queue entry for tracking
Skip Sent Folder Copies
For bulk sending, you might not want to save 1000 copies to the Sent Mail folder:
{
"subject": "Newsletter",
"html": "<p>Weekly digest</p>",
"copy": false,
"mailMerge": [{ "to": { "address": "user1@example.com" } }, { "to": { "address": "user2@example.com" } }]
}
Personalization with Handlebars
Basic Personalization
Inject per-recipient data using Handlebars syntax:
curl -XPOST "https://ee.example.com/v1/account/example/submit" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{
"subject": "Test message for {{{params.nickname}}}",
"html": "<p>Hello {{params.nickname}}, welcome to our service!</p>",
"mailMerge": [
{
"to": {
"name": "Ada Lovelace",
"address": "ada@example.com"
},
"params": {
"nickname": "ada"
}
},
{
"to": {
"name": "Grace Hopper",
"address": "grace@example.com"
},
"params": {
"nickname": "grace"
}
}
]
}'
Important: For plaintext fields (subject, text) use triple braces {{{…}}} so Handlebars doesn't HTML-escape characters.
For HTML fields, use double braces {{…}} to escape HTML entities, or triple braces if you want to inject raw HTML.
Built-in Variables
EmailEngine provides built-in variables you can reference:
Available variables:
{{account.email}}- The sender's email address{{account.name}}- The sender's display name{{service.url}}- EmailEngine instance URL{{params.*}}- Your custom parameters
Complex Personalization
Include rich personalization data:
{
"subject": "Your order #{{{params.orderNumber}}} has shipped",
"html": `
<h1>Hi {{params.firstName}},</h1>
<p>Your order <strong>{{params.orderNumber}}</strong> has shipped!</p>
<p>Tracking: <a href='{{params.trackingUrl}}'>{{params.trackingNumber}}</a></p>
<p>Total: ${{params.orderTotal}}</p>
`,
"mailMerge": [
{
"to": { "address": "ada@example.com" },
"params": {
"firstName": "Ada",
"orderNumber": "12345",
"orderTotal": "99.99",
"trackingNumber": "1Z999AA10123456784",
"trackingUrl": "https://tracking.example.com/1Z999AA10123456784"
}
}
]
}
Conditional Content
Use Handlebars helpers for conditional content:
With data:
{
"mailMerge": [
{
"to": { "address": "ada@example.com" },
"params": {
"firstName": "Ada",
"isPremium": true,
"items": [
{ "name": "Product A", "price": "29.99" },
{ "name": "Product B", "price": "39.99" }
]
}
}
]
}
Using with Templates
Combine mail merge with stored templates for maximum efficiency.
Create a Template
First, create a template via API or UI:
curl -XPOST "https://ee.example.com/v1/templates/template" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"account": null,
"name": "Welcome Email",
"description": "Welcome new users",
"content": {
"subject": "Welcome {{{params.nickname}}}!",
"html": "<h1>Hello {{params.nickname}}</h1><p>Welcome to our service!</p>",
"text": "Hello {{params.nickname}}\n\nWelcome to our service!"
}
}'
Response:
{
"created": true,
"account": null,
"id": "AAABgggrm00AAAABZWtpcmk"
}
Use Template with Mail Merge
Reference the template ID in your mail merge:
curl -XPOST "https://ee.example.com/v1/account/example/submit" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{
"template": "AAABgggrm00AAAABZWtpcmk",
"mailMerge": [
{
"to": {
"name": "Ada Lovelace",
"address": "ada@example.com"
},
"params": {
"nickname": "ada"
}
},
{
"to": {
"name": "Grace Hopper",
"address": "grace@example.com"
},
"params": {
"nickname": "grace"
}
}
]
}'
EmailEngine:
- Loads the template
- Applies each recipient's
paramsto the template - Generates individual messages
- Queues each for delivery
Advanced Features
Per-Recipient Custom Message-ID
Override the Message-ID for specific recipients:
{
"subject": "Team Update",
"html": "<p>Update for {{params.teamName}}</p>",
"mailMerge": [
{
"to": { "address": "alice@example.com" },
"messageId": "<custom-id-alice@example.com>",
"params": {
"teamName": "Engineering"
}
},
{
"to": { "address": "bob@example.com" },
"params": {
"teamName": "Sales"
}
}
]
}
Note: Each mailMerge entry accepts a single recipient address. For sending to multiple recipients, use separate merge entries.
Scheduled Mail Merge
Schedule the entire merge for future sending:
{
"subject": "Newsletter",
"html": "<p>Weekly update</p>",
"sendAt": "2025-12-25T09:00:00.000Z",
"mailMerge": [{ "to": { "address": "user1@example.com" } }, { "to": { "address": "user2@example.com" } }]
}
All messages will be queued and sent at the specified time.
Rate Limiting and Throttling
Provider Limits
Be aware of provider sending limits:
- Gmail: ~500 recipients/day for free accounts, ~2000 for Google Workspace
- Outlook: ~300 recipients/day for personal, ~10,000 for business
- Yahoo: ~500 recipients/day
- Custom SMTP: Check with your provider
Implement Throttling
For large merges, consider:
- Batch Processing: Split large merges into smaller batches
const recipients = [...]; // Large list
const batchSize = 100;
for (let i = 0; i < recipients.length; i += batchSize) {
const batch = recipients.slice(i, i + batchSize);
await fetch('/v1/account/example/submit', {
method: 'POST',
headers: { 'Authorization': 'Bearer <token>' },
body: JSON.stringify({
subject: 'Newsletter',
html: '<p>Content</p>',
mailMerge: batch.map(r => ({
to: { address: r.email },
params: { name: r.name }
}))
})
});
// Wait between batches
await new Promise(resolve => setTimeout(resolve, 60000));
}
-
Scheduled Delivery: Spread messages over time using
sendAt -
Monitor Queue: Watch the outbox queue to avoid overload
Check Queue Status
Monitor the queue to ensure you're not overwhelming the system:
curl "https://ee.example.com/v1/outbox" \
-H "Authorization: Bearer <token>"
Look for:
- Number of waiting jobs
- Number of delayed jobs
- Any failed jobs
Tracking Delivery
Per-Message Tracking
Each mail merge entry gets a unique Message-ID and queue ID:
{
"mailMerge": [
{
"success": true,
"to": { "address": "ada@example.com" },
"messageId": "<unique-id-1@example.com>",
"queueId": "abc123"
},
{
"success": true,
"to": { "address": "grace@example.com" },
"messageId": "<unique-id-2@example.com>",
"queueId": "def456"
}
]
}
Store these IDs in your database to track delivery status.
Webhook Events
Each message triggers its own webhooks:
messageSent (per recipient):
{
"event": "messageSent",
"data": {
"messageId": "<unique-id-1@example.com>",
"queueId": "abc123",
"envelope": {
"from": "sender@example.com",
"to": ["ada@example.com"]
}
}
}
messageDeliveryError (if retry needed):
{
"event": "messageDeliveryError",
"data": {
"queueId": "abc123",
"messageId": "<unique-id-1@example.com>",
"error": "Connection timeout",
"job": {
"attemptsMade": 1,
"attempts": 10,
"nextAttempt": "2025-05-14T15:07:45.465Z"
}
}
}
messageFailed (if all retries exhausted):
{
"event": "messageFailed",
"data": {
"queueId": "abc123",
"messageId": "<unique-id-1@example.com>",
"error": "Max retries exceeded"
}
}
Track Delivery Status
Build a tracking system:
// Store merge results
const mergeSendResult = await sendMailMerge(...);
const deliveryTracking = mergeSendResult.mailMerge.map(entry => ({
recipient: entry.to.address,
messageId: entry.messageId,
queueId: entry.queueId,
status: 'queued',
timestamp: new Date()
}));
// Save to database
await db.deliveryTracking.insertMany(deliveryTracking);
// Webhook handler
app.post('/webhook', async (req, res) => {
const { event, data } = req.body;
if (event === 'messageSent') {
await db.deliveryTracking.updateOne(
{ messageId: data.messageId },
{ $set: { status: 'sent', sentAt: data.date } }
);
} else if (event === 'messageFailed') {
await db.deliveryTracking.updateOne(
{ messageId: data.messageId },
{ $set: { status: 'failed', error: data.error } }
);
}
res.sendStatus(200);
});
Common Pitfalls
Template Escaping
Problem: Forgetting triple braces leads to subjects like <Welcome>.
{
"subject": "Welcome {{params.name}}" // Wrong for plain text!
}
Solution: Use triple braces for non-HTML fields:
{
"subject": "Welcome {{{params.name}}}", // Correct!
"html": "Hello {{params.name}}" // Double braces for HTML
}
Queue Timeouts
Problem: Each generated message gets its own queue entry; if your merge size is huge, watch /v1/outbox for items that exceed EmailEngine's processing window.
Solution:
- Break large merges into smaller batches (100-500 per batch)
- Monitor queue depth
- Scale EmailEngine vertically (increase CPU/RAM and worker configuration)
Unwanted Sent Copies
Problem: Mailbox gets filled with thousands of sent copies.
Solution: Set "copy": false in your payload:
{
"copy": false,
"mailMerge": [...]
}
Missing Parameters
Problem: Template references {{params.name}} but some recipients don't have that parameter.
{
"mailMerge": [
{
"to": { "address": "ada@example.com" },
"params": { "name": "Ada" }
},
{
"to": { "address": "bob@example.com" },
"params": {} // Missing 'name'!
}
]
}
Result: Second email shows empty value or "undefined"
Solution: Always provide all required params, or use Handlebars conditionals:
Rate Limit Exceeded
Problem: Sending too many messages too quickly.
Solution:
- Implement batching with delays
- Use
sendAtto schedule over time - Check provider limits
- Monitor error webhooks
Performance Optimization
Use Templates
Pre-create templates instead of including HTML in every API call:
- Reduces payload size
- Faster processing
- Easier to update content
Optimize Params
Keep param objects lean:
- Only include necessary data
- Avoid large nested objects
- Don't duplicate account-level data
Monitor Performance
Track metrics:
- Merge request processing time
- Queue depth
- Delivery success rate
- Error rate by recipient domain
Testing Mail Merge
Test with Small Batch
Always test with a small batch first:
{
"subject": "Test merge",
"html": "<p>Hello {{params.name}}</p>",
"mailMerge": [
{
"to": { "address": "test1@ethereal.email" },
"params": { "name": "Test User 1" }
},
{
"to": { "address": "test2@ethereal.email" },
"params": { "name": "Test User 2" }
}
]
}
Verify Personalization
Check that each recipient gets personalized content:
- Send to test addresses
- Check each inbox
- Verify variables were replaced correctly
- Confirm no HTML escaping issues
Test Error Handling
Test with invalid data to ensure graceful failure:
- Invalid email addresses
- Missing required params
- Oversized attachments