` |
The signature is computed as:
```
signing_string = webhook_id + "." + timestamp + "." + payload
signature = HMAC-SHA256(secret, signing_string)
```
Always verify webhook signatures before processing events. This prevents
attackers from sending fake events to your endpoint.
### Idempotent Consumers
Webhook consumers should handle duplicate events. Use the `X-Webhook-Id` header to deduplicate.
Each destination is retried independently — a failure at one endpoint does not cause re-delivery to any other endpoint that already received the batch. Duplicates at a given endpoint come only from retries against that same endpoint (for example, when your server returns a 5xx and unMTA retries the same batch).
### Verifying Webhook Signatures
{/* prettier-ignore */}
```php
$payload = file_get_contents('php://input');
$webhookId = $_SERVER['HTTP_X_WEBHOOK_ID'];
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'];
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'];
$signingString = $webhookId . '.' . $timestamp . '.' . $payload;
$expectedSignature = 'v1=' . hash_hmac('sha256', $signingString, $secret);
if (!hash_equals($expectedSignature, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
$events = json_decode($payload, true);
foreach ($events as $event) {
// Process each event
}
````
{/* prettier-ignore */}
```javascript
const crypto = require('crypto');
app.post('/webhook', (req, res) => {
const payload = JSON.stringify(req.body);
const webhookId = req.headers['x-webhook-id'];
const timestamp = req.headers['x-webhook-timestamp'];
const signature = req.headers['x-webhook-signature'];
const signingString = `${webhookId}.${timestamp}.${payload}`;
const expectedSignature = 'v1=' + crypto
.createHmac('sha256', secret)
.update(signingString)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(signature)
)) {
return res.status(401).send('Invalid signature');
}
for (const event of req.body) {
// Process each event
}
res.status(200).send('OK');
});
````
{/* prettier-ignore */}
```python
import hmac
import hashlib
import json
from flask import Flask, request
app = Flask(**name**)
@app.route('/webhook', methods=['POST'])
def webhook():
payload = request.get_data(as_text=True)
webhook_id = request.headers.get('X-Webhook-Id')
timestamp = request.headers.get('X-Webhook-Timestamp')
signature = request.headers.get('X-Webhook-Signature')
signing_string = f"{webhook_id}.{timestamp}.{payload}"
expected_signature = 'v1=' + hmac.new(
secret.encode(),
signing_string.encode(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected_signature, signature):
return 'Invalid signature', 401
events = json.loads(payload)
for event in events:
# Process each event
pass
return 'OK', 200
````
{/* prettier-ignore */}
```go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
)
func webhookHandler(w http.ResponseWriter, r *http.Request) {
payload, _ := io.ReadAll(r.Body)
webhookId := r.Header.Get("X-Webhook-Id")
timestamp := r.Header.Get("X-Webhook-Timestamp")
signature := r.Header.Get("X-Webhook-Signature")
signingString := webhookId + "." + timestamp + "." + string(payload)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingString))
expectedSignature := "v1=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expectedSignature), []byte(signature)) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
var events []map[string]interface{}
json.Unmarshal(payload, &events)
for _, event := range events {
// Process each event
}
w.WriteHeader(http.StatusOK)
}
````
### Handling Failures
When your endpoint returns a 4xx or 5xx status code, unMTA will retry the batch to that destination with exponential backoff. Retries are scoped to the destination that failed — other destinations in the same batch are not re-delivered. To stay safe against retries against your own endpoint, make your consumer idempotent:
- Use the `X-Webhook-Id` header to deduplicate requests
- Store processed webhook IDs and skip duplicates
- Return 200 OK as soon as you've received the events
## Log Shipping
Log shipping periodically uploads event logs to S3-compatible storage. Use log shipping when you need to archive events for compliance, run batch analytics, or integrate with data warehouses.
### Supported Storage Services
**AWS S3** — Native Amazon S3 with region selection
**S3-Compatible Services:**
- MinIO
- DigitalOcean Spaces
- Backblaze B2
- Cloudflare R2
- Wasabi
- Any S3-compatible API
### Upload Frequency
| Frequency | Use Case |
| ---------- | ----------------------------------------------- |
| 5 minutes | Near real-time analysis, high-volume monitoring |
| 10 minutes | |
| 15 minutes | |
| 30 minutes | Balance of timeliness and efficiency |
| 1 hour | Standard operational logging |
| 3 hours | |
| 6 hours | |
| 12 hours | |
| 24 hours | Daily archival, compliance retention |
Logs are uploaded as compressed JSON files (zstd compression).
### Configuring Log Shipping
1. Navigate to the **Events** page from the sidebar
2. In the S3 Log Shipping section, click **Edit Log Shipping**
3. Enable the **Enable S3 log shipping** toggle
4. Select your service type:
- **AWS S3** — Enter your region
- **S3 Compatible** — Enter your endpoint URL
5. Enter your bucket name
6. Optionally enter a path prefix (e.g., `logs/unmta/`)
7. Enter your access key and secret key
8. Select your upload frequency
9. Click **Test Connection** to verify access
10. Click **Save**
### S3 Bucket Requirements
Your S3 credentials need the following permissions:
| Permission | Required | Purpose |
| ----------------- | -------- | ------------------------------------------ |
| `s3:PutObject` | Yes | Upload log files |
| `s3:DeleteObject` | No | Clean up test files during connection test |
**Example IAM Policy (AWS):**
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::your-bucket-name/*"
}
]
}
```
### Log File Format
Log files are uploaded with the following characteristics:
- **Format**: JSON, zstd compressed
- **Structure**: One event per line (JSONL format)
- **Organization**: Files organized by timestamp in your bucket
- **Content**: Same event payload structure as webhooks
## Best Practices
### For Webhooks
- **Always verify signatures** — Each destination has its own secret; reject requests with invalid signatures to prevent spoofing
- **Respond quickly** — Return 200 OK immediately after receiving; process events asynchronously
- **Handle retries** — Use `X-Webhook-Id` for deduplication; retries against your endpoint can produce duplicate deliveries
- **Use HTTPS** — Encrypt webhook traffic to protect event data in transit
- **Monitor failures** — Each destination is retried independently, but a chronically failing endpoint will fall behind and eventually exhaust its retry budget
### For Log Shipping
- **Choose appropriate frequency** — Higher frequencies provide fresher data but create more small files
- **Use path prefixes** — Organize logs by environment or purpose (e.g., `production/unmta/`)
- **Set retention policies** — Configure S3 lifecycle rules to manage storage costs
- **Secure credentials** — Use IAM roles with minimal required permissions
---
# Getting Started (/docs/index)
## Overview
unMTA provides email delivery infrastructure for your applications. You can send email using either SMTP or HTTP—choose whichever fits your application best.
This guide walks you through the initial setup: adding a domain, creating credentials, and sending your first message.
## Prerequisites
Before you begin, you'll need:
- Access to the unMTA dashboard (from your [onboarding](/onboarding))
- A domain you control (for DNS configuration)
- An application that needs to send email
## 1. Add Your Domain
Domains must be verified before you can send email through unMTA. Verification confirms you control the domain and enables DKIM signing for your messages.
1. Navigate to **Domains** in the sidebar
2. Click **Add domain**
3. Enter your domain name (e.g., `example.com`)
4. Click **Save**
The dashboard displays the DNS records you'll need to configure.
See [Domains](/docs/domains) for complete details on domain verification and
DNS configuration.
## 2. Configure DNS Records
Add these DNS records to your domain. The exact values are shown in the dashboard after adding your domain.
| Record Type | Name | Purpose |
| ----------- | ------------------ | --------------------------- |
| TXT | `unmta._domainkey` | DKIM public key for signing |
| TXT | `_dmarc` | DMARC policy (recommended) |
DNS changes can take up to 48 hours to propagate, though most providers update
within minutes. See [Domains](/docs/domains) for provider-specific
instructions.
## 3. Verify Your Domain
Once DNS records are in place:
1. Return to the **Domains** page
2. Click the menu on your domain row
3. Select **Check DNS**
If records are configured correctly, the domain status changes to **Verified** and you can start sending email.
## 4. Create Credentials
Credentials authenticate your application when sending email.
1. Navigate to **Credentials** in the sidebar
2. Click **Add credentials**
3. Enter a username for the credential
4. Select which domains this credential can send from
5. Click **Save**
Copy the password immediately—it's only shown once. If you lose it, you'll
need to delete the credential and create a new one.
You can restrict credentials to specific domains and IP addresses. See [Credentials](/docs/credentials) for details.
## 5. Send Your First Email
With a verified domain and credentials, you're ready to send. Choose SMTP or HTTP based on your application.
To find your cluster's SMTP hostname and HTTP endpoint, see [Clusters](/docs/clusters).
### SMTP
Connect to your cluster's SMTP endpoint on port 587 with STARTTLS:
```bash
swaks --to recipient@example.com \
--from sender@yourdomain.com \
--server your-cluster.unmta.net:587 \
--tls \
--auth-user your-credential \
--auth-password your-password \
--header "Subject: Test Message" \
--body "Hello from unMTA!"
```
### HTTP
POST to the injection endpoint with HTTP Basic Auth:
```bash
curl -X POST https://your-cluster.unmta.net/api/inject/v1 \
-u "username:password" \
-H "Content-Type: application/json" \
-d '{
"envelope_sender": "bounces@yourdomain.com",
"recipients": ["user@example.com"],
"content": {
"from": "sender@yourdomain.com",
"subject": "Hello from unMTA",
"text_body": "This is a test email."
}
}'
```
See [Sending Messages](/docs/message-injection) for complete options including scheduling and campaign headers.
## What's Next
- **Track delivery** — Set up [webhooks or log shipping](/docs/events) to monitor email events
- **Manage queues** — Use the [Queues](/docs/queues) page to monitor and control delivery
- **Automate setup** — Use the [API](/docs/api) to manage domains and credentials programmatically
---
# Sending Messages (/docs/message-injection)
## Overview
Message injection is how you submit email for delivery through your unMTA cluster. You can inject messages using either SMTP or HTTP—choose whichever fits your application best.
Before sending email, you'll need:
- **A verified domain** — See [Domains](/docs/domains) to add and verify your sending domain
- **A credential** — See [Credentials](/docs/credentials) to create SMTP/HTTP credentials
## SMTP Injection
Connect to your cluster's SMTP endpoint using any standard SMTP client or library.
### Connection Settings
| Setting | Value |
| -------------- | ------------------------------ |
| **Host** | Your cluster's SMTP endpoint |
| **Port** | 25, 587, or 2587 |
| **Encryption** | STARTTLS required on all ports |
| **Auth** | SMTP AUTH PLAIN |
To find your cluster's SMTP hostname and HTTP endpoint, see [Clusters](/docs/clusters).
### Requirements
- **STARTTLS required** — Authentication only accepted over encrypted connection
- **FROM header required** — Used for DKIM signing
- **Domain validation** — MAIL FROM and FROM header domains must be verified and allowed for your credential
- **Max message size** — 10 MB
### Example
Using [swaks](https://www.jetmore.org/john/code/swaks/) (Swiss Army Knife for SMTP):
```bash
swaks --to recipient@example.com \
--from sender@yourdomain.com \
--server your-cluster.unmta.net:587 \
--tls \
--auth-user your-credential \
--auth-password your-password \
--header "Subject: Test Message" \
--body "Hello from unMTA!"
```
## HTTP Injection
Submit messages via HTTP POST for applications that prefer REST APIs over SMTP.
### Endpoint
```
POST https://your-cluster.unmta.net/api/inject/v1
```
### Authentication
HTTP Basic Auth using your credential username and password.
### Request Format
Send a JSON object with:
| Field | Required | Description |
| ----------------- | -------- | ------------------------------------------------------------------------------ |
| `envelope_sender` | Yes | Bounce address for delivery failures |
| `recipients` | Yes | Array of recipient email addresses |
| `content` | Yes | Message content object |
| `tracking` | No | Open/click tracking opt-in ([see below](#tracking-open-and-click-via-api)) |
### Content Object
| Field | Required | Description |
| ----------- | -------- | -------------------------------------- |
| `from` | Yes | From header (used for DKIM signing) |
| `subject` | No | Subject line |
| `text_body` | No | Plain text body |
| `html_body` | No | HTML body |
| `reply_to` | No | Reply-To address |
| `headers` | No | Additional headers as key-value object |
Include at least one of `text_body` or `html_body`. For best deliverability,
include both so recipients without HTML support see the plain text version.
### Example
```bash
curl -X POST https://your-cluster.unmta.net/api/inject/v1 \
-u "username:password" \
-H "Content-Type: application/json" \
-d '{
"envelope_sender": "bounces@yourdomain.com",
"recipients": ["user@example.com"],
"content": {
"from": "sender@yourdomain.com",
"subject": "Hello from unMTA",
"text_body": "This is the plain text version.",
"html_body": "This is the HTML version.
"
}
}'
```
### Response
On success, the API returns HTTP 200 with details about the injected message.
| Status | Meaning |
| ------ | -------------------------------------------- |
| 200 | Message accepted for delivery |
| 401 | Invalid credentials |
| 429 | Rate limited (too many failed auth attempts) |
### Template Substitution
HTTP injection supports template substitution, allowing you to personalize messages for each recipient without making separate API calls.
#### Template Variables
Variables are populated from three sources, in order of precedence:
1. **Per-recipient substitutions** — `recipients[].substitutions` (highest priority)
2. **Global substitutions** — `substitutions` at the request level
3. **Built-in variables** — `name` and `email` from each recipient
Substitution values can be any JSON type—strings, numbers, objects, or arrays.
#### Syntax
Use double curly braces to insert variables:
```
Hello {{ name }}, your order #{{ order_id }} has shipped!
```
#### Template Dialects
Control templating behavior with the `template_dialect` field:
| Dialect | Description |
| ------------ | ---------------------------------------- |
| `Jinja` | [MiniJinja](https://docs.rs/minijinja/latest/minijinja/) engine—Jinja2-compatible syntax (default) |
| `Handlebars` | Handlebars-compatible engine |
| `Static` | No template expansion—send content as-is |
MiniJinja supports most common Jinja2 features including filters, conditionals, and loops, plus additional functions like `now()`, `dateformat()`, and `pluralize()`.
#### Scope
Templates are applied to:
- `text_body`
- `html_body`
- `headers`
Attachments are **not** subject to template substitution.
#### Example
Send a personalized message to multiple recipients:
```bash
curl -X POST https://your-cluster.unmta.net/api/inject/v1 \
-u "username:password" \
-H "Content-Type: application/json" \
-d '{
"envelope_sender": "bounces@yourdomain.com",
"substitutions": {
"company": "Acme Corp"
},
"recipients": [
{
"email": "alice@example.com",
"name": "Alice",
"substitutions": { "order_id": "12345" }
},
{
"email": "bob@example.com",
"name": "Bob",
"substitutions": { "order_id": "67890" }
}
],
"content": {
"from": "orders@yourdomain.com",
"subject": "Your {{ company }} order has shipped",
"text_body": "Hi {{ name }},\n\nYour order #{{ order_id }} is on its way!\n\nThanks,\n{{ company }}"
}
}'
```
This sends two personalized messages:
- Alice receives: "Hi Alice, Your order #12345 is on its way! Thanks, Acme Corp"
- Bob receives: "Hi Bob, Your order #67890 is on its way! Thanks, Acme Corp"
To send content containing literal curly braces without substitution, set
`template_dialect` to `Static`.
### Tracking (Open and Click) via API
Open and click tracking can be turned on per message by including an optional `tracking` object on the request.
| Field | Type | Description |
| --------- | ------- | ---------------------------------------------------------------------------- |
| `opens` | boolean | Enable (or explicitly disable) open tracking for this message |
| `clicks` | boolean | Enable (or explicitly disable) click tracking for this message |
| `domain` | string | Override the tracking domain for this message. Must be verified in your cluster |
All three fields are optional. The effect is identical to setting the corresponding `X-UNMTA-Track-Opens`, `X-UNMTA-Track-Clicks`, and `X-UNMTA-Tracking-Domain` headers in the message — see the [Tracking guide](/docs/tracking) for the full feature reference.
```bash
curl -X POST https://your-cluster.unmta.net/api/inject/v1 \
-u "username:password" \
-H "Content-Type: application/json" \
-d '{
"envelope_sender": "bounces@yourdomain.com",
"recipients": ["user@example.com"],
"content": {
"from": "sender@yourdomain.com",
"subject": "Hello",
"html_body": "Check out our site!
"
},
"tracking": {
"opens": true,
"clicks": true,
"domain": "track.yourdomain.com"
}
}'
```
**Precedence.** If both the `tracking` object and the equivalent `X-UNMTA-Track-*` header are set on the same message, the `tracking` object wins. Setting `opens: false` or `clicks: false` explicitly disables tracking even if the message carries an enabling header — useful for suppressing tracking on transactional or legally-sensitive messages while reusing a templated body.
The `tracking` object is only supported when `content` is sent as a structured
object. If `content` is a raw RFC822 string, the request returns `400` — in
that case, place the `X-UNMTA-Track-*` headers in the message directly, since
you already control its MIME.
## Special Headers
These headers control message behavior during injection. Include them in your message headers for SMTP, or in the `headers` object for HTTP injection.
### X-Schedule
Schedule messages for future delivery by including an `X-Schedule` header with a JSON value specifying when delivery should occur.
**Deferred delivery** — Delay the first delivery attempt until a specific time:
```
X-Schedule: {"first_attempt":"2024-03-01T17:00:00-08:00"}
```
**Time-window delivery** — Restrict delivery to specific days and times:
```
X-Schedule: {"dow":"Mon,Tue,Wed,Thu,Fri","tz":"America/New_York","start":"09:00:00","end":"17:00:00"}
```
This example delivers only on weekdays between 9 AM and 5 PM Eastern time. Messages received outside this window are held until the next valid delivery time.
| Parameter | Description |
| ----------------- | ---------------------------------------------------------------------------- |
| **first_attempt** | RFC 3339 timestamp for earliest delivery (e.g., `2024-03-01T17:00:00-08:00`) |
| **dow** | Days of week for delivery (e.g., `Mon,Wed,Fri`) |
| **tz** | IANA timezone name (e.g., `America/Phoenix`) |
| **start** | Window start time in `HH:MM:SS` format |
| **end** | Window end time in `HH:MM:SS` format |
Time-window parameters (`dow`, `tz`, `start`, `end`) must all be specified
together. You can combine them with `first_attempt` to delay until a specific
time and then apply window constraints.
### X-Campaign
Assign messages to a campaign for logical grouping and management:
```
X-Campaign: welcome-series
```
Campaigns let you organize related messages and manage them together:
- **Promotional sends** — Group messages from a marketing campaign
- **Transactional categories** — Separate order confirmations from shipping notifications
- **A/B testing** — Track different message variants independently
Messages with the same campaign are grouped in the [queue view](/docs/queues) and can be suspended or bounced as a unit. When suspending or bouncing a queue, you can target a specific campaign to affect only those messages while leaving other traffic to the same domain unaffected.
If no campaign is specified, messages are grouped by destination domain only.
The `X-Campaign` header is removed from the message before delivery.
## Rate Limiting
unMTA does not rate limit message injection. However, it does include brute-force protection to prevent credential stuffing attacks by temporarily blocking IPs with repeated failed authentication attempts.
### How It Works
- Failed authentication attempts are tracked per IP address
- After the threshold is exceeded, the IP is temporarily blocked
- IPv6 addresses are grouped by prefix to prevent address rotation attacks
### When Blocked
If you exceed the failed authentication threshold:
- **SMTP:** `421 4.7.0 Too many failed authentication attempts. Try again later.`
- **HTTP:** `429 Too Many Requests` with `Retry-After` header
If you're blocked due to failed attempts, wait before trying again. The block
expires automatically after a short period.
## Message Validation
Both SMTP and HTTP injection apply these validations:
| Check | Description |
| ------------------------ | ------------------------------------------------------- |
| **Domain verified** | Sender domain must be verified in your cluster |
| **Domain authorized** | Sender domain must be allowed for your credential |
| **FROM header required** | Message must include a FROM header for DKIM signing |
| **Domain alignment** | FROM header domain must match an allowed sending domain |
Messages failing validation are rejected with an appropriate error message.
## Bounce Addresses
unMTA automatically generates bounce addresses for delivery tracking. When a message bounces, the bounce notification is sent to this generated address, allowing unMTA to correlate bounces with the original message.
The generated bounce address format is:
```
{message_id}@c-{cluster_id}.unmta.net
```
Your original envelope sender is preserved in message metadata and included in [event payloads](/docs/events).
## Best Practices
### Choose the Right Method
- **SMTP** — Best for applications with existing SMTP infrastructure, email clients, or when streaming large volumes
- **HTTP** — Best for REST API integrations, serverless environments, or when you need template substitution
### Validate Before Sending
Ensure your sending domain is verified and your credential has access to it before attempting injection. Check the [Domains](/docs/domains) page for domain status.
### Handle Errors Gracefully
- **401, 429 errors** — Check your credential username and password
- **Domain errors** — Verify the sending domain is verified and allowed for your credential
### Use Campaigns for Organization
Assign campaigns to logically group related messages. This makes it easier to monitor delivery and take action on specific message types without affecting other traffic.
---
# Queues (/docs/queues)
## Overview
The Queues page provides visibility into email delivery queues and allows administrative control over message flow. Messages waiting for delivery are organized into queues by destination domain and optionally by campaign.
unMTA provides three views of your queues:
| View | Purpose |
| -------------------- | -------------------------------------------------------- |
| **Scheduled Queues** | Active queues with messages waiting for delivery |
| **Suspended Queues** | Paused queues where delivery is temporarily halted |
| **Bounced Queues** | Queues where messages are being administratively bounced |
You can view queues across an entire cluster or filter to a specific MTA using the selector in the top right.
## Scheduled Queues
The Scheduled Queues tab shows all active delivery queues. Each queue represents messages destined for a specific domain, optionally grouped by campaign.
| Column | Description |
| ------------ | ------------------------------------ |
| **Queue** | Destination domain (e.g., gmail.com) |
| **Campaign** | Campaign identifier, if any |
| **Messages** | Number of messages waiting in queue |
Results are limited to the top 200 queues by message count. If your cluster
has more queues, some may not be displayed.
From any queue row, you can click the menu to suspend or bounce that specific queue.
You can schedule messages for future delivery using the `X-Schedule` header,
and assign messages to campaigns using the `X-Campaign` header. See [Sending
Messages](/docs/message-injection#special-headers) for details.
## Suspended Queues
Suspended queues have delivery temporarily paused. Messages remain in the queue but won't be processed until the suspension expires or is manually removed.
| Column | Description |
| ------------ | -------------------------------------------- |
| **Domain** | Destination domain affected |
| **Campaign** | Specific campaign, or all if not specified |
| **Reason** | Explanation provided when suspending |
| **Duration** | Time remaining until suspension auto-expires |
To resume delivery, click the trash icon to delete the suspension.
## Bounced Queues
Bounced queues are being administratively cleared. Matching messages are immediately bounced and removed, and new messages arriving for the same destination are also bounced until the rule expires.
| Column | Description |
| ------------ | --------------------------------------------- |
| **Domain** | Destination domain affected |
| **Campaign** | Specific campaign, or all if not specified |
| **Reason** | Explanation provided when bouncing |
| **Duration** | Time remaining until bounce rule auto-expires |
| **Bounced** | Number of messages bounced so far |
To stop bouncing new messages, click the trash icon to delete the bounce rule.
## Suspending a Queue
Suspending a queue pauses delivery without discarding messages. Use this when you need to temporarily halt delivery—for example, during recipient server maintenance or while investigating delivery issues.
### Configuration
| Field | Required | Description |
| ------------ | -------- | --------------------------------------------------------- |
| **Domain** | Yes | Destination domain to suspend (e.g., `gmail.com`) |
| **Campaign** | No | Limit to specific campaign; leave empty for all campaigns |
| **Duration** | Yes | How long to suspend (hours + minutes, minimum 1 minute) |
| **Reason** | Yes | Explanation logged with the suspension |
### How to Suspend
**From a scheduled queue:**
1. In the Scheduled Queues tab, click the menu icon on a queue row
2. Select **Suspend [domain]**
3. Adjust duration and enter a reason
4. Click **Suspend**
**For any domain:**
1. Click **Options** in the top right
2. Select **Suspend a Queue**
3. Enter the domain (and optionally campaign)
4. Set duration and reason
5. Click **Suspend**
### Suspension Behavior
- Messages remain queued but delivery attempts stop immediately
- The suspension applies to the entire cluster or selected MTA
- Suspension auto-expires after the specified duration
- You can manually delete the suspension to resume delivery early
## Bouncing a Queue
Bouncing a queue permanently discards matching messages. Each bounced message generates an `AdminBounce` event in your logs (unless logging is suppressed).
This is a destructive operation. Bounced messages are immediately removed and
cannot be recovered.
### Configuration
| Field | Required | Description |
| -------------------- | -------- | --------------------------------------------------------------- |
| **Domain** | Yes | Destination domain to bounce |
| **Campaign** | No | Limit to specific campaign; leave empty for all campaigns |
| **Duration** | Yes | How long to bounce new messages (hours + minutes, min 1 minute) |
| **Reason** | Yes | Logged in delivery logs and bounce records |
| **Suppress logging** | No | Prevents `AdminBounce` log records for each bounced message |
### How to Bounce
**From a scheduled queue:**
1. In the Scheduled Queues tab, click the menu icon on a queue row
2. Select **Bounce [domain]**
3. Adjust duration and enter a reason
4. Optionally enable **Suppress logging**
5. Click **Bounce**
**For any domain:**
1. Click **Options** in the top right
2. Select **Bounce a Queue**
3. Enter the domain (and optionally campaign)
4. Set duration and reason
5. Click **Bounce**
### Bounce Behavior
- Existing messages in matching queues are immediately bounced and removed
- New messages arriving during the duration are also bounced
- Each bounced message generates an `AdminBounce` event (unless suppressed)
- The bounce rule auto-expires after the specified duration
- You can manually delete the bounce rule to stop bouncing new messages
AdminBounce events appear in your webhooks and log shipping if configured. See
[Events](/docs/events) for details on the event payload.
## Removing Suspensions and Bounces
### Removing a Suspension
1. Navigate to the **Suspended Queues** tab
2. Find the suspension you want to remove
3. Click the trash icon on that row
4. Delivery resumes immediately for the affected queue
### Removing a Bounce Rule
1. Navigate to the **Bounced Queues** tab
2. Find the bounce rule you want to remove
3. Click the trash icon on that row
4. New messages will no longer be bounced (already bounced messages are gone)
## Best Practices
### When to Suspend
- **Temporary delivery issues** — Pause while a recipient server is experiencing problems
- **Investigation** — Hold messages while investigating high bounce rates
- **Coordination** — Pause delivery while coordinating timing with a recipient domain
- **Maintenance** — Halt delivery during your own maintenance windows
### When to Bounce
- **Invalid domains** — Clear messages for domains confirmed as invalid or abandoned
- **Recipient request** — Honor requests to remove all pending messages
- **Business decision** — Stop all delivery to a domain based on policy
- **Cleanup** — Clear test or unwanted messages from queues
### Duration Guidelines
| Scenario | Suggested Duration |
| ---------------------------- | ------------------ |
| Quick investigation | 5–15 minutes |
| Recipient server maintenance | 1–4 hours |
| Extended investigation | 24 hours |
| Permanent removal (bounce) | 24+ hours |
### Logging Considerations
- **Keep logging enabled** for audit trails unless you have a specific reason not to
- **Suppress logging** only for high-volume bounces where individual records aren't needed
- **AdminBounce events** are valuable for tracking what was removed and why
---
# Tracking (/docs/tracking)
## Overview
Tracking domains let you brand the open and click-tracking URLs in your outbound email under a domain you control (e.g. `track.example.com`) instead of an unMTA-owned hostname. Recipients see your brand in the URLs that show up in their browser bar and link previews, which improves trust, deliverability, and analytics attribution.
A tracking domain is a subdomain you point at unMTA via a single CNAME record. Once verified, unMTA terminates HTTPS for that hostname automatically (no certificate to manage on your side) and rewrites tracking links in delivered email to use it.
Each tracking domain belongs to a single cluster. You can run multiple tracking domains in the same cluster and mark one as the **default**, which is used for any message that doesn't override the tracking host explicitly.
## Enabling Tracking on a Message
Open and click tracking are opt-in per message. There are two equivalent ways to turn them on:
- **In-message headers.** Works for both SMTP and HTTP injection. The sender controls the MIME and adds the opt-in headers directly.
- **HTTP API fields.** HTTP injection only. The injecting system passes a `tracking` object on the request, without needing to mutate the message body — useful when the system that builds the message and the system that injects it are different.
Either method strips the opt-in from the outbound message so recipients never see it.
### Using In-Message Headers
| Header | Value | Effect |
| ----------------------- | ----- | ------------------------------------------------------------------------------------------------- |
| `X-UNMTA-Track-Opens` | `1` | Injects a 1×1 tracking pixel into the `text/html` part. Messages with no HTML part are a no-op. |
| `X-UNMTA-Track-Clicks` | `1` | Rewrites `http(s)://` links in the `text/html` part to route through unMTA for click logging. |
HTTP injection example:
```bash
curl -X POST https://c-your-cluster-id.unmta.net/api/inject/v1 \
-u "username:password" \
-H "Content-Type: application/json" \
-d '{
"envelope_sender": "bounces@yourdomain.com",
"recipients": ["user@example.com"],
"content": {
"from": "sender@yourdomain.com",
"subject": "Hello",
"html_body": "Check out our promo!
",
"headers": {
"X-UNMTA-Track-Opens": "1",
"X-UNMTA-Track-Clicks": "1"
}
}
}'
```
Only `` tags with `http://` or `https://` hrefs are rewritten. `mailto:`, `tel:`, `sms:`, `javascript:`, `data:`, anchor-only (`#section`), and relative URLs are left untouched.
### Using HTTP API Fields
When injecting via the HTTP API, you can turn tracking on with a `tracking` object on the request body instead of embedding headers:
```bash
curl -X POST https://c-your-cluster-id.unmta.net/api/inject/v1 \
-u "username:password" \
-H "Content-Type: application/json" \
-d '{
"envelope_sender": "bounces@yourdomain.com",
"recipients": ["user@example.com"],
"content": {
"from": "sender@yourdomain.com",
"subject": "Hello",
"html_body": "Check out our promo!
"
},
"tracking": {
"opens": true,
"clicks": true
}
}'
```
`tracking.opens`, `tracking.clicks`, and `tracking.domain` have identical effect to their `X-UNMTA-Track-*` counterparts. If both are set on the same message, the API field wins — including `opens: false` or `clicks: false`, which explicitly suppress tracking even when the message carries an enabling header.
The API field approach only works with structured `content`. If `content` is a raw RFC822 string, the request returns `400` — put the opt-in headers in the message directly instead, since you already control its MIME.
### Disabling Click Tracking on Specific Links
When click tracking is enabled you can still opt individual links out by adding the `data-unmta-no-track` attribute. This is useful for unsubscribe links, legal footers, or any URL you don't want routed through a tracker. The attribute is stripped from the outbound HTML:
```html
Unsubscribe
Terms
```
Both the presence-only and `="true"` forms work.
### Choosing the Tracking Domain Per Message
By default, tracked URLs use your cluster's [default tracking domain](#default-tracking-domain) if one is configured, or an unMTA-owned hostname (`c-.unmta.net`) otherwise. To override per message — for example, to brand links by customer or campaign — either add the `X-UNMTA-Tracking-Domain` header:
```
X-UNMTA-Tracking-Domain: track.example.com
```
…or, when injecting via HTTP, pass `tracking.domain` on the request body:
```json
{ "tracking": { "domain": "track.example.com" } }
```
Both are matched case-insensitively against your cluster's verified tracking domains and are stripped from the outbound message. Selection happens in this order:
1. `X-UNMTA-Tracking-Domain` value, if it names a verified domain in this cluster
2. The cluster's default tracking domain, if one is set
3. The unMTA-owned fallback (`c-.unmta.net`)
If the header names a domain that isn't verified in your cluster, it's silently ignored and selection falls through to step 2. The send still succeeds — a misconfigured header never fails delivery.
## How It Works
When you add a tracking domain, unMTA issues a unique **verification target** — a hostname like `v-deadbeef.track.unmta.net`. You add a single CNAME record at your DNS provider pointing your tracking subdomain at that target. The same record proves ownership and routes live tracking traffic.
Behind the scenes, when a recipient opens a tracking URL on `track.example.com`, the request lands on unMTA's edge, which:
1. Looks up the hostname against the verified tracking domains registered to your cluster
2. Terminates TLS using a certificate it provisions and renews automatically
3. Records the open or click event and serves the appropriate response
There's nothing for you to install, run, or rotate.
## Adding a Tracking Domain
1. Navigate to the **Tracking Domains** page from the sidebar
2. Click the **Add tracking domain** button
3. Enter your tracking subdomain (e.g., `track.example.com`)
4. Optionally toggle **Set as default** to make this the cluster's default tracking domain once verified
5. Click **Add tracking domain**
You'll be redirected to the tracking domain detail page showing the CNAME record you need to configure.
### Tracking Domain Requirements
- Must be a valid subdomain — at least three labels (e.g. `track.example.com`), not an apex domain
- Cannot be an IP address
- Cannot use reserved TLDs: `localhost`, `local`, `test`, `invalid`, `example`, `localdomain`
- Must be unique within your cluster (the same name can exist in different clusters)
Tracking subdomains can be anything you control — common choices are
`track.example.com`, `links.example.com`, or `email.example.com`. Pick
something short and stable since it will appear in every link your recipients
see.
## DNS Record Setup
After adding a tracking domain, you'll see a single CNAME record on the detail page:
| Record | Name | Value |
| ------ | ------------------- | --------------------------------------- |
| CNAME | `track.example.com` | `v-deadbeef.track.unmta.net` (yours) |
Click the copy button next to the target value to copy it to your clipboard.
### Provider-Specific Instructions
{/* prettier-ignore */}
1. Log in to your Cloudflare dashboard
2. Select your domain
3. Go to **DNS** → **Records**
4. Click **Add record**
5. Configure the record:
- **Type**: CNAME
- **Name**: The subdomain portion (e.g., `track`)
- **Target**: Paste the verification target from your tracking domain detail page
- **Proxy status**: **DNS only** (grey cloud — do not proxy through Cloudflare)
- **TTL**: Auto
6. Click **Save**
The CNAME record must be **DNS only** (grey cloud). If Cloudflare's orange-cloud proxy is enabled, verification will fail and live tracking traffic will be intercepted.
{/* prettier-ignore */}
1. Open the Route 53 console
2. Select **Hosted zones** and click your domain
3. Click **Create record**
4. Configure the record:
- **Record name**: The subdomain portion (e.g., `track`)
- **Record type**: CNAME
- **Value**: Paste the verification target
- **TTL**: 300 (or your preference)
5. Click **Create records**
{/* prettier-ignore */}
The general steps for adding a CNAME record are similar across DNS providers:
1. Log in to your DNS provider's management console
2. Navigate to your domain's DNS settings
3. Add a new CNAME record:
- **Type**: CNAME
- **Name/Host**: The subdomain portion (e.g., `track`)
- **Value/Target**: The verification target from your tracking domain detail page
- **TTL**: Use the default or set to 3600 (1 hour)
4. Save your changes
Most providers automatically append your domain to the record name. Enter only the subdomain portion (e.g., `track`) rather than the full `track.example.com`.
DNS changes can take up to 48 hours to propagate, though most updates appear
within a few minutes to a few hours.
## Verifying a Tracking Domain
Once you've added the CNAME record:
1. Go to your tracking domain's detail page
2. Click the **Verify Now** button
unMTA walks the CNAME chain from your domain and confirms it ends at the issued verification target. Once it does, the status flips to **Active** and the domain is ready for use.
If verification fails, double-check the CNAME record is in place and pointing at the correct target — DNS propagation can take time, and any proxy layer (such as Cloudflare's orange-cloud proxy) will prevent the chain from resolving.
### Ongoing Monitoring
Active tracking domains are automatically re-checked once every 24 hours to make sure the CNAME still resolves correctly. If the record changes or disappears:
1. **First failed check**: You'll receive a warning email. The domain stays active to give you time to fix the issue.
2. **Second consecutive failed check**: The domain is downgraded to **Pending** status and you'll receive a notification. Tracking links in already-delivered mail will stop working until the CNAME is restored and the domain is verified again.
This two-strike policy prevents brief DNS issues from immediately disrupting tracking while still catching persistent problems.
## Tracking Domain Status
Tracking domains have two possible statuses:
| Status | Meaning |
| ----------- | -------------------------------------------------------------------------------------------------- |
| **Pending** | CNAME not yet verified. Tracking links cannot be served from this domain. |
| **Active** | CNAME verified and the domain is serving tracking traffic. |
## Default Tracking Domain
Each cluster can have at most one **default** tracking domain. The default is used to rewrite tracking URLs in any outbound message that doesn't specify a tracking host explicitly.
To set the default:
1. Go to the tracking domain's detail page (the domain must be **Active**)
2. Click **Set as default**
Setting a domain as default automatically clears the flag from any other tracking domain in the same cluster. To remove the default entirely (so messages fall back to the unMTA-owned hostname), open the current default and click **Unset as default** — it's fine to have no default configured.
Only verified (active) tracking domains can be set as default. Pending domains
can be added to your cluster but won't accept the default flag until they're
verified.
## Managing Tracking Domains
### Viewing Tracking Domain Details
Click any tracking domain in the list to view its detail page, which shows:
- Current verification status and default flag
- The CNAME record with copy button
- A **Verify Now** button while pending
- A **Set/Unset as default** button while active
### Deleting a Tracking Domain
To delete a tracking domain:
1. Go to the tracking domain's detail page
2. Click the menu button and select **Delete tracking domain**
3. Confirm the deletion
Deleting a tracking domain is immediate and irreversible. Tracking links in
any already-delivered mail that pointed at this domain will stop working —
recipients will see broken links if they click them.
## API Reference
For programmatic tracking domain management, see the [Tracking API documentation](/docs/api#tracking-domains).