Transactional Email Service
EmailEngine can serve as a self-hosted transactional email service in front of any email account it manages. You submit messages over the REST API or SMTP, schedule them, route them through an SMTP gateway, follow delivery through webhooks, and get bounces reported back as they arrive in the mailbox.
Overview
The pieces involved:
- Two submission methods: the REST API or the built-in SMTP server
- Queuing: every submission goes through the submit queue, with retries on temporary failures
- Scheduled sending: hold a message until a given time
- Gateways: deliver through a dedicated SMTP relay instead of the account's own server
- Bounce detection: a delivery status notification arriving in the mailbox becomes a
messageBouncewebhook - Sent Mail copy: an SMTP delivery is uploaded to the account's Sent Mail folder unless disabled. Gmail and Microsoft Graph file the sent message themselves, so the
copyflag is a no-op on those accounts - Reply flags: a reply sent with
referenceflags the original as\Answered
Delivery via REST API
Submit Endpoint
Submit a message with POST /v1/account/{account}/submit. EmailEngine turns the JSON into an RFC 822 message, so you do not build MIME yourself: strings are Unicode, attachments are base64, and the headers a message needs are generated.
Basic Example
curl -XPOST "https://emailengine.example.com/v1/account/example/submit" \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{
"from": {
"name": "Example Sender",
"address": "sender@example.com"
},
"to": [{
"name": "John Doe",
"address": "john@example.com"
}],
"subject": "Hello from EmailEngine",
"text": "Plain text message",
"html": "<p>HTML message</p>",
"attachments": [
{
"filename": "document.pdf",
"content": "JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwo+PgplbmRvYmoK"
}
]
}'
Response:
{
"response": "Queued for delivery",
"messageId": "<188db4df-3abb-806c-94c8-7a9303652c50@example.com>",
"sendAt": "2025-10-15T10:30:00.000Z",
"queueId": "24279fb3e0dff64e"
}
Reply to Existing Message
With reference, EmailEngine takes the subject, the recipients, and the threading headers from the stored message:
curl -XPOST "https://emailengine.example.com/v1/account/example/submit" \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{
"reference": {
"message": "AAAAAQAAP1w",
"action": "reply"
},
"from": {
"name": "Support Team",
"address": "support@example.com"
},
"text": "Thank you for your message. We will review and get back to you.",
"html": "<p>Thank you for your message. We will review and get back to you.</p>"
}'
What EmailEngine fills in:
- Subject from the original, with a
Re:prefix In-Reply-ToandReferences- The recipients of a reply
- The
\Answeredflag on the original once the reply is sent
Any field you supply, such as subject or to, wins over the derived value:
{
"reference": {
"message": "AAAAAQAAP1w",
"action": "reply"
},
"subject": "Custom reply subject",
"text": "Reply content"
}
See Replies and forwards for every reference option.
Attachments
Attachments are base64-encoded in content:
{
"from": { "address": "sender@example.com" },
"to": [{ "address": "recipient@example.com" }],
"subject": "File attached",
"text": "Please find the file attached.",
"attachments": [
{
"filename": "report.pdf",
"content": "JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwo+PgplbmRvYmoK",
"contentType": "application/pdf"
},
{
"filename": "image.png",
"content": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
"contentType": "image/png",
"cid": "unique-cid-123"
}
]
}
Attachment properties:
filename: the file name shown to the recipientcontent: the base64-encoded file contentcontentType(optional): MIME type, derived fromfilenameif omittedcid(optional): Content-ID, for images referenced from the HTML
Inline Images
Reference an inline image from the HTML through its cid:
{
"from": { "address": "sender@example.com" },
"to": [{ "address": "recipient@example.com" }],
"subject": "Image email",
"html": "<p>Check out this image:</p><img src=\"cid:logo-image\" />",
"attachments": [
{
"filename": "logo.png",
"content": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
"contentType": "image/png",
"cid": "logo-image"
}
]
}
Delivery via SMTP
EmailEngine includes an SMTP submission server, off by default, for software that speaks SMTP rather than HTTP. SMTP server is the reference for it; this section covers what a transactional setup needs.
Enable the SMTP Server
- Open Configuration > SMTP Server in the admin interface
- Check Enable SMTP Server
- Set Port (
2525by default) and, if needed, Listen Address - Check Require Authentication and set a Global Password
- Save
The same settings are smtpServerEnabled, smtpServerPort, smtpServerHost, smtpServerAuthEnabled, and smtpServerPassword on POST /v1/settings. smtpServerTLSEnabled switches the listener to implicit TLS; STARTTLS is never offered, in either mode. smtpServerProxy enables the HAProxy PROXY protocol for a load balancer in front.
The listener starts on 127.0.0.1:2525, so it is reachable from the EmailEngine host only until Listen Address is widened.
Authentication
The server accepts AUTH PLAIN and AUTH LOGIN. The username is the account ID, and the password is either the global password from the settings or an access token with the smtp scope. To build the PLAIN credential by hand:
echo -ne "\0example\0your_password" | base64
With authentication switched off, the message has to name the account instead, in an X-EE-Account header that EmailEngine strips before delivery.
Manual SMTP Session
Run this on the EmailEngine host, since the listener is bound to the loopback address by default:
nc localhost 2525
EHLO client.example.com
AUTH PLAIN AGV4YW1wbGUAeW91cl9wYXNzd29yZA==
MAIL FROM:<sender@example.com>
RCPT TO:<recipient@example.com>
DATA
From: sender@example.com
To: recipient@example.com
Subject: Test Email
X-EE-Send-At: 2025-10-16T14:00:00.000Z
This is the email body.
.
QUIT
The server answers DATA with 250 Message queued for delivery as <queueId> (<sendAt>), where queueId is the same value the REST API returns.
Control Headers
Submit options that a message can carry as headers are listed in full in EmailEngine options as headers. For transactional sending the useful ones are:
X-EE-Send-At: an ISO 8601 timestamp; the message is held until thenX-EE-Delivery-Attempts: overrides the retry count for this messageX-EE-Gateway: a gateway ID, see Delivery through a gatewayX-EE-Tracking-Enabled: turns open and click tracking on or off for this messageX-EE-Idempotency-Key: collapses a repeated submission of the same message into one deliveryX-EE-Account: the account to send through, only when authentication is disabled
Every control header is removed before the message goes out.
What the SMTP Server Does with a Message
- Only the
RCPT TOaddresses receive the message;To,Cc, andBccare informational - A
Bccheader is removed before delivery Message-ID,Date, andMIME-Versionare added if missing- With an
X-EE-Send-Atin the future, theDateheader is rewritten to the scheduled time
Delivery through a Gateway
By default a message goes out through the account's own SMTP server or provider API. An SMTP gateway is a separate relay registered with EmailEngine, for bulk or transactional mail that should not count against the mailbox's own sending limits.
Register one with POST /v1/gateway. It takes gateway (the ID you will refer to it by), name, host and port as required fields, plus the optional user, pass and secure (true for implicit TLS, usually on port 465). Four more endpoints manage the registry:
| Endpoint | Purpose |
|---|---|
GET /v1/gateways | List the registered gateways |
GET /v1/gateway/{gateway} | Read one gateway, including its last use and last error |
PUT /v1/gateway/edit/{gateway} | Change host, port, credentials or name |
DELETE /v1/gateway/{gateway} | Remove it |
Name the gateway on submit to route a message through it:
{
"from": { "address": "sender@example.com" },
"to": [{ "address": "recipient@example.com" }],
"subject": "Order confirmation",
"text": "Your order has shipped.",
"gateway": "transactional-relay"
}
Over SMTP the equivalent is the X-EE-Gateway header. The message still belongs to the account: its Sent Mail copy, bounce detection, and webhooks work as for any other submission.
Scheduled Sending
API Scheduling
Set sendAt to an ISO 8601 timestamp:
curl -XPOST "https://emailengine.example.com/v1/account/example/submit" \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{
"from": {
"address": "sender@example.com"
},
"to": [{
"address": "recipient@example.com"
}],
"subject": "Scheduled Email",
"text": "This email was scheduled for delivery.",
"sendAt": "2025-10-18T08:00:00.000Z"
}'
The response echoes the time:
{
"response": "Queued for delivery",
"messageId": "<3b1d4c2a-7f0e-4d55-9a1c-2c9e7f8b6a10@example.com>",
"sendAt": "2025-10-18T08:00:00.000Z",
"queueId": "24279fb3e0dff64e"
}
SMTP Scheduling
Add an X-EE-Send-At header:
From: sender@example.com
To: recipient@example.com
Subject: Scheduled Email
X-EE-Send-At: 2025-10-18T08:00:00.000Z
This email will be sent at the scheduled time.
Time Format
ISO 8601 with a timezone designator:
2025-10-18T08:00:00.000Z
2025-10-18T08:00:00+02:00
2025-10-18T08:00:00-05:00
How Scheduling Behaves
- A
sendAtin the past sends immediately - There is no upper bound on how far ahead a message can be scheduled
- Until
sendAt, the message sits in the submit queue as a delayed job and is listed by the outbox API, which is also where it can be deleted
Webhook Notifications
Delivery is reported through four events. Each has a reference page with the full payload; the fields that matter for a transactional integration are:
| Event | When | Fields to correlate on |
|---|---|---|
messageSent | The receiving server accepted the message | messageId, queueId, response, and originalMessageId, which differs from messageId when the server rewrote it |
messageDeliveryError | A delivery attempt failed; another is scheduled unless it was the last | queueId, messageId, error, errorCode, smtpResponse, smtpResponseCode, and job.nextAttempt |
messageFailed | The job is over: the attempts ran out, or a permanent rejection ended it early | queueId, messageId, error |
messageBounce | A delivery status notification arrived in the mailbox | messageId of the bounced message, recipient, action, response.status, bounceMessage |
A messageSent example:
{
"account": "example",
"date": "2025-10-15T10:30:05.000Z",
"event": "messageSent",
"data": {
"messageId": "<188db4df-3abb-806c-94c8-7a9303652c50@example.com>",
"response": "250 2.0.0 OK queued as 1234ABCD",
"queueId": "24279fb3e0dff64e",
"envelope": {
"from": "sender@example.com",
"to": ["recipient@example.com"]
}
}
}
messageDeliveryError is emitted on every failed attempt, including the final one, which is then followed by messageFailed. The messageBounce payload identifies the original message by its messageId; its queueId field, when present, is the queue ID reported by the bouncing MTA, not EmailEngine's.
Bounce Detection
EmailEngine watches the account's mailbox for delivery status notifications and reports them as messageBounce webhooks.
How It Works
- The message is submitted and handed to the SMTP server or provider API
- The server accepts it, and
messageSentis emitted - Delivery to the final recipient fails later, and the responsible MTA sends a bounce to the sender address
- The bounce arrives in the monitored mailbox
- EmailEngine parses it and emits
messageBouncewith the failed recipient, the status, and theMessage-IDof the original message
Bounce Types
Hard bounce (action: "failed"):
- Permanent failure
- Unknown or invalid address
- Domain does not exist
- Mailbox disabled
Soft bounce (action: "delayed"):
- Temporary failure
- Mailbox full
- Server temporarily unavailable
- The MTA keeps retrying
Bounce Information
A messageBounce payload carries:
- recipient: the address that failed
- action:
failed(permanent) ordelayed(temporary) - response.status: the enhanced status code, for example
5.1.1 - response.message: the server's diagnostic text
- mta: the server that reported the failure
- messageId: the
Message-IDof the original message - bounceMessage: the EmailEngine ID of the bounce email itself
The webhook is sent only when the report yields action, recipient and messageId together; a bounce that cannot be tied to a sent message is logged instead. With classification enabled, response also carries category, recommendedAction, blocklist and retryAfter. Bounces has the full field list and the categories.
Tracking Bounces
To correlate a bounce with the message it is about:
1. Store the messageId when sending:
const response = await fetch('https://emailengine.example.com/v1/account/example/submit', {
method: 'POST',
headers: {
'Authorization': 'Bearer TOKEN',
'Content-Type': 'application/json'
},
body: JSON.stringify({
from: { address: 'sender@example.com' },
to: [{ address: 'recipient@example.com' }],
subject: 'Test',
text: 'Content'
})
});
const data = await response.json();
await db.messages.insert({
queueId: data.queueId,
messageId: data.messageId,
recipient: 'recipient@example.com',
status: 'queued'
});
If the sending server rewrites Message-IDs, update the stored value from the messageSent webhook whenever its messageId differs from its originalMessageId, or the bounce will name an ID you never stored. Message-ID rewriting lists the servers EmailEngine can detect this for.
2. Match the bounce webhook to the original:
app.post('/webhooks', async (req, res) => {
const event = req.body;
if (event.event === 'messageBounce') {
const messageId = event.data.messageId;
const original = await db.messages.findOne({ messageId });
if (original) {
await db.messages.update(
{ messageId },
{
status: 'bounced',
bounceReason: event.data.response.message,
bounceStatus: event.data.response.status
}
);
await handleBounce(original, event.data);
}
}
res.json({ success: true });
});
Queue Management
Submissions are BullMQ jobs in the submit queue. Outbox queue is the reference for the queue itself; the parts a transactional integration touches are below.
Queue Monitoring
Open System > Queues in the admin interface for the Bull Board dashboard and select the submit queue. A job is in one of:
- Waiting: ready to send
- Delayed: scheduled for later, or waiting out a retry delay
- Active: being sent
- Completed: delivered
- Failed: no attempts left
Retry Behavior
A temporary failure is retried with exponential backoff: 5 seconds after the first failed attempt, then 10, 20, 40 seconds and so on, with jitter shortening each delay by up to 20% so that a batch of failures does not retry in lockstep. The default is 10 attempts, set globally as Retry Attempts under Configuration > Email Processing (deliveryAttempts on POST /v1/settings) and per message with deliveryAttempts on submit or the X-EE-Delivery-Attempts header. A permanent rejection, an SMTP 5xx other than 503, ends the job before the attempts run out. Outbox queue has the full classification.
Manual Queue Management
From Bull Board, a failed job can be retried and a job in any state can be removed. Pausing the queue holds every waiting job until it is resumed; the same is available as PUT /v1/settings/queue/submit with {"paused": true} or {"paused": false}. A queued message can also be removed with DELETE /v1/outbox/{queueId}.
Queue Performance
For high-volume sending:
- Watch the Waiting count; a growing backlog means submissions outpace delivery
- Check the receiving server's rate limits before raising concurrency; see Performance tuning
- Review the Failed tab for the rejections behind repeated
messageDeliveryErrorevents
See Also
- SMTP server - The submission interface this page relays through
- Outbox queue - Retry behavior and how to watch a backlog
- Bounces - Recognizing a rejection that arrives as mail
- Blocklists - Suppressing addresses that have already bounced
- Delivery testing - Checking SPF, DKIM, and DMARC before a campaign