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
Sending receipts, onboarding tips or weekly digests from your customer's own mailbox keeps the mail in that mailbox's Sent folder and under its reputation, but a single message with 500 addresses in the To header exposes every recipient to every other one. A mail merge turns one REST call into one message per recipient, each addressed to that recipient alone.
How Mail Merge Works
Instead of calling the submit API multiple times, you:
- Drop
to/cc/bccfrom your payload - Add a
mailMergearray with one entry per recipient - EmailEngine fans out the request into distinct messages, each with its own Message-ID and queue entry
- Handlebars placeholders in the subject and body are filled in from each entry's
params
Each mailMerge entry accepts these fields:
| Field | Description |
|---|---|
to | The recipient, as a single { "name", "address" } object. Required |
params | Values for the Handlebars placeholders in this recipient's copy |
messageId | A Message-ID for this recipient's copy, instead of a generated one |
sendAt | When to send this copy. Overrides the message-level sendAt |
Because each entry supplies its own addressing and rendering, the message-level to, cc, bcc, envelope, raw, and render fields are rejected when mailMerge is present, and a message-level messageId is ignored. There is no per-recipient cc or bcc; a copy goes to its one to address.
Basic Mail Merge
Broadcasting Same Content
Send the same message to multiple recipients without exposing addresses:
curl -XPOST "https://emailengine.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:
{
"mailMerge": [
{
"success": true,
"to": {
"name": "Ada Lovelace",
"address": "ada@example.com"
},
"messageId": "<91853631-2329-7f13-a4df-da377d873787@example.com>",
"queueId": "182080c50b63e7e232a",
"sendAt": "2025-05-14T09:12:23.123Z"
},
{
"success": true,
"to": {
"name": "Grace Hopper",
"address": "grace@example.com"
},
"messageId": "<8b47f91c-06f3-b555-ee19-2c99908aff25@example.com>",
"queueId": "182080c50f283f49252",
"sendAt": "2025-05-14T09:12:23.123Z"
}
]
}
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
The entries are queued in parallel and reported one by one. An entry that could not be queued has "success": false with error, code, and statusCode in place of the IDs, while the other entries go ahead; the HTTP status of the call is still 200. A dryRun returns no preview for a mail merge.
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://emailengine.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: Plain-text fields (subject, text) are rendered without HTML escaping, so double braces {{…}} are always safe there - triple braces are never needed.
For HTML fields (html, previewText), use double braces {{…}} to escape HTML entities, or triple braces {{{…}}} if you want to inject raw HTML.
Rendering happens only for entries that carry params (or for every entry when the merge names a listId). An entry without a params key is otherwise sent with the placeholders left in place as literal text, so give every entry a params object, even an empty {}, whenever the content contains placeholders.
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 (theserviceUrlsetting, or the message'sbaseUrl){{params.*}}- Your custom parameters{{rcpt.unsubscribeUrl}}- A signed one-click unsubscribe link for this recipient. Present only when the merge names alistId; see virtual mailing lists
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://emailengine.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://emailengine.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. An entry can carry its own sendAt, which overrides the message-level one for that recipient, so a merge can be spread over time from a single call:
{
"subject": "Newsletter",
"html": "<p>Weekly update</p>",
"mailMerge": [
{ "to": { "address": "user1@example.com" }, "sendAt": "2025-12-25T09:00:00.000Z" },
{ "to": { "address": "user2@example.com" }, "sendAt": "2025-12-25T09:05:00.000Z" }
]
}
Unsubscribe Handling
Name a listId (a subdomain-shaped identifier, registered on first use) and EmailEngine adds List-ID, List-Unsubscribe, and List-Unsubscribe-Post headers to every copy, with a signed one-click unsubscribe link per recipient. A recipient who has unsubscribed from that list is skipped: their response entry carries "skipped": { "reason": "unsubscribe", "listId": "weekly-digest" } instead of a queue ID. listId is only accepted together with mailMerge, and the headers need serviceUrl to be set. Virtual mailing lists covers the whole flow.
Rate Limiting and Throttling
Provider Limits
A mail merge sends through the account's own mailbox, so the provider's per-day recipient limit applies. The figures below are the published ones at the time of writing, and providers revise them, so treat them as an order of magnitude and check the provider's own page before planning a campaign around them:
- Gmail: around 500 recipients a day on a free account, around 2000 on Google Workspace (Google's limits)
- Outlook: around 300 a day on a personal account, around 10,000 on a business one (Microsoft's limits)
- Yahoo: around 500 a day
- Anything else: ask the provider
Exceeding the limit does not fail quietly: the provider rejects the submission, and EmailEngine reports it as a messageDeliveryError or messageFailed depending on whether the rejection is retriable.
Implement Throttling
For large merges, consider:
- Batch Processing: Split large merges into smaller batches
const recipients = [
{ email: 'ada@example.com', name: 'Ada' },
{ email: 'grace@example.com', name: 'Grace' },
{ email: 'linus@example.com', name: 'Linus' }
];
const batchSize = 100;
for (let i = 0; i < recipients.length; i += batchSize) {
const batch = recipients.slice(i, i + batchSize);
await fetch('https://emailengine.example.com/v1/account/example/submit', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EMAILENGINE_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
subject: 'Newsletter',
html: '<p>Hello {{params.name}}</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://emailengine.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 (on every failed delivery attempt, including the final one):
{
"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 response = await fetch('https://emailengine.example.com/v1/account/example/submit', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EMAILENGINE_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
subject: 'Hello {{params.name}}',
html: '<p>Hello {{params.name}}</p>',
mailMerge: [
{ to: { address: 'ada@example.com' }, params: { name: 'Ada' } },
{ to: { address: 'grace@example.com' }, params: { name: 'Grace' } }
]
})
});
const mergeSendResult = await response.json();
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: Parameter values containing HTML markup are escaped in HTML content. Double braces in html HTML-escape the value, so a param like <b>Ada</b> renders as literal text instead of bold.
{
"html": "Hello {{params.signature}}"
}
Solution: Use triple braces in HTML content only when you intend to inject raw HTML:
{
"html": "Hello {{{params.signature}}}"
}
Plain-text fields (subject, text) are never HTML-escaped, so double braces are always correct there - escaped subjects like <Welcome> cannot occur.
Large Merges
Problem: Each generated message gets its own queue entry, and the submit workers drain the queue at the rate the account's SMTP server accepts messages. A large merge therefore takes a while to leave, and the entries that are still waiting count against everything else queued for the instance.
Solution:
- Break large merges into smaller batches (100-500 per batch)
- Watch
totalin/v1/outboxto see the queue drain - Raise the submit worker concurrency (
EENGINE_SUBMIT_QC, see performance tuning) when the SMTP server can take more parallel connections
Unwanted Sent Copies
Problem: Mailbox gets filled with thousands of sent copies.
Solution: Set "copy": false in your payload:
{
"copy": false,
"mailMerge": [{ "to": { "address": "ada@example.com" } }]
}
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": {}
}
]
}
Result: The second email renders an empty string where the name should be. (Had the entry carried no params key at all, the copy would not have been rendered and {{params.name}} would appear literally.)
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
See Also
- Templates - Storing the subject and body instead of repeating them per send
- Virtual mailing lists - Unsubscribe handling for a recurring send
- Outbox queue - Watching a large merge drain
- Blocklists - Keeping unsubscribed and bounced addresses out of a merge
- Sending API - The endpoint reference