Skip to main content

Error Codes Reference

Every HTTP status the EmailEngine API answers with, every code string an API error body can carry, and the provider, SMTP and OAuth2 errors that reach you through webhooks and the account's lastError.

Error Response Format

Every API error is a JSON body in this shape:

{
"statusCode": 404,
"error": "Not Found",
"message": "Requested message was not found",
"code": "MessageNotFound"
}
FieldTypeDescription
statusCodenumberHTTP status code, repeated in the body
errorstringHTTP status phrase, such as Not Found
messagestringHuman-readable reason for the failure
codestringMachine-readable error code. Present only for the failures listed under EmailEngine Error Codes and Provider-Mapped Codes
fieldsarrayValidation failures only: one {message, key} entry per rejected input
statestringAccount-state failures (503) only: the account's current state
ttlnumberRate limit responses (429) only: seconds until the window resets
requestedScopestringScope refusals (403) only: the scope the route required
details, infoobject or arrayExtra context a few routes attach, such as delivery test results or the IMAP server's response to a mailbox operation
error is the status text, not the reason

Show and log message. The error field only repeats the HTTP status phrase, so a handler that reports error tells the operator "Bad Request" instead of what was actually wrong.

HTTP Status Codes

2xx Success

CodeNameMeaning
200OKRequest succeeded

200 is the only success code the API returns. A creation reports what it created in the body of a 200 rather than answering 201, and no endpoint answers 204.


4xx Client Errors

400 Bad Request

The request itself is wrong: a missing or malformed field, invalid JSON, or an operation that contradicts stored data.

Validation failure:

{
"statusCode": 400,
"error": "Bad Request",
"message": "Invalid input",
"fields": [
{ "message": "\"account\" is required", "key": "account" }
]
}

Input validation failures carry a fields array naming each rejected input, and no code. Read fields to report the specific problem back to the caller. An invalid recipient list or a submit call with no recipients is rejected this way, not with a dedicated code.

OAuth2 user already bound:

Registering or updating an OAuth2 account for a user who is already bound to another account under the same OAuth2 application answers 400 with the code AccountAlreadyExists and names the other account. EmailEngine does not use 409 Conflict. Re-registering an existing account ID is not an error: POST /v1/account updates the account in place.

{
"statusCode": 400,
"error": "Bad Request",
"message": "Another account for the same OAuth2 user already exists",
"code": "AccountAlreadyExists",
"existingAccount": "user-a"
}

401 Unauthorized

No usable access token was presented. Neither variant carries a code; the reason a token was refused (unknown, expired, malformed) is written to the log, not returned to the caller.

No credential:

{
"statusCode": 401,
"error": "Unauthorized",
"message": "Unauthorized"
}

Credential refused:

{
"statusCode": 401,
"error": "Unauthorized",
"message": "Bad token",
"attributes": {
"error": "Bad token"
}
}

Solutions:

  • Send the token as Authorization: Bearer TOKEN (or the access_token query parameter)
  • Check that the token has not been deleted or expired; issue a new one if needed

When EENGINE_REQUIRE_API_AUTH=false, a request with no token is accepted instead of answering 401.


403 Forbidden

The token is valid but is not allowed to do this. A 403 carries a descriptive message and no code:

MessageCause
Unauthorized scopeThe token lacks the scope the route requires. The body also carries requestedScope
Unauthorized permissionThe token's permissions record does not admit this action or group
Unauthorized accountThe token is bound to a different account
Unauthorized addressThe caller's IP is not in the token's address restrictions
Unauthorized referrerThe Referer header is not in the token's referrer restrictions
{
"statusCode": 403,
"error": "Forbidden",
"message": "Unauthorized scope",
"requestedScope": "api"
}

The license endpoints also answer 403 when the operation fails: GET /v1/license when license information cannot be loaded, POST /v1/license when the key is invalid or expired, and DELETE /v1/license when the key cannot be removed.


404 Not Found

The addressed entity does not exist. A missing account is a plain 404 with no code:

{
"statusCode": 404,
"error": "Not Found",
"message": "Account record was not found for requested ID"
}

A missing message, folder, template, webhook route, OAuth2 application or gateway carries a code:

{
"statusCode": 404,
"error": "Not Found",
"message": "Requested message was not found",
"code": "MessageNotFound"
}

SMTPUnavailable is also a 404: the account has no SMTP or OAuth2 configuration to send with.


413 Payload Too Large

Two sources. A request body over the route's limit is refused by the HTTP server before the handler runs; message upload routes accept up to EENGINE_MAX_BODY_SIZE (50 MB by default), other routes 1 MB. A Microsoft Graph account whose provider rejects an outgoing message for size answers with the mapped code MessageTooLarge.


422 Unprocessable Entity

The request is well formed, but the account's backend cannot carry it out. The only code is MissingServerExtension: the IMAP server lacks an extension the operation needs, for example a label filter on a mailbox that is not Gmail.

{
"statusCode": 422,
"error": "Unprocessable Entity",
"message": "Server does not support X-GM-EXT-1 extension required for label search",
"code": "MissingServerExtension"
}

Do not retry: the answer will not change until the account or the server does.


429 Too Many Requests

The token's rate limit is exhausted.

{
"statusCode": 429,
"error": "Too Many Requests",
"message": "Rate limit exceeded",
"ttl": 60
}

Response headers:

X-RateLimit-Limit: 1000
X-RateLimit-Reset: 60

X-RateLimit-Reset and ttl both carry the seconds until the window resets, not a Unix timestamp. X-RateLimit-Remaining is only sent on requests that were allowed.

Retry logic:

async function makeRequestWithRetry(url, options, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
const response = await fetch(url, options);

if (response.status === 429) {
const data = await response.json();
const waitTime = data.ttl || Math.pow(2, i);
await new Promise(resolve => setTimeout(resolve, waitTime * 1000));
continue;
}

return response;
}

throw new Error('Max retries exceeded');
}

5xx Server Errors

500 Internal Server Error

An error the handler did not classify. The body is the generic Boom message; a code is present when the underlying error carried one.

{
"statusCode": 500,
"error": "Internal Server Error",
"message": "An internal server error occurred"
}

Retry after a delay, then check the EmailEngine log for the request.


503 Service Unavailable

The account is not in a state that can serve the request. The body carries the account's state alongside the code:

{
"statusCode": 503,
"error": "Service Unavailable",
"message": "Requested account can not be authenticated",
"code": "AuthenticationFails",
"state": "authenticationError"
}
CodeStateMeaning
NotYetConnectedinitThe account has not connected yet. Wait
AuthenticationFailsauthenticationErrorThe credentials are rejected. Update them or re-authorize
ConnectionErrorconnectErrorThe mail server cannot be reached. Check the host, port and network
NotSyncingunsetSyncing is switched off, by the operator or by the authentication-failure safety net. Check authFailureDisabledAt on the account
NoAvailableotherThe account is disconnected or paused
IMAPUnavailableanyThe account's IMAP connection is not up right now. Retry shortly

504 Gateway Timeout

The API worker asked an account worker to do something and got no answer within EENGINE_TIMEOUT (10 seconds by default; a request can raise it with the X-EE-Timeout header, in milliseconds).

{
"statusCode": 504,
"error": "Gateway Timeout",
"message": "Timeout waiting for command response [T2]",
"code": "Timeout"
}

A slow IMAP server on a large mailbox is the usual cause. Raise EENGINE_TIMEOUT or send X-EE-Timeout for the operations that need it.


EmailEngine Error Codes

Every code the API itself puts in an error body:

CodeStatusWhen
AccountAlreadyExists400The OAuth2 user is already bound to another account under the same OAuth2 application; the body names it in existingAccount
AccountNotFound404Export endpoints only: the account does not exist
MessageNotFound404The message ID does not exist in the mailbox
FolderNotFound404The mailbox path does not exist
NotFound404The template, webhook route, OAuth2 application or gateway does not exist
SMTPUnavailable404The account has no SMTP or OAuth2 configuration to send with
MissingServerExtension422The IMAP server lacks an extension the operation needs
NotYetConnected503The account has not connected yet
AuthenticationFails503The account's credentials are rejected
ConnectionError503The mail server cannot be reached
NotSyncing503Syncing is switched off for the account
NoAvailable503The account is disconnected or paused
IMAPUnavailable503The IMAP connection is not available at the moment
Timeout504A worker thread did not answer within EENGINE_TIMEOUT

Provider-Mapped Codes

For Gmail API and Microsoft Graph accounts, a provider error that the operation cannot recover from is translated to an EmailEngine code and status and returned from the API call that triggered it.

Gmail API:

Gmail statusCodeHTTP status
INVALID_ARGUMENTInvalidArgument400
FAILED_PRECONDITIONFailedPrecondition400
NOT_FOUNDNotFound404
PERMISSION_DENIEDPermissionDenied403
RESOURCE_EXHAUSTEDRateLimitExceeded429
UNAUTHENTICATEDUnauthenticated401
INTERNALInternalError500
UNAVAILABLEServiceUnavailable503

Microsoft Graph:

Graph error codeCodeHTTP status
ErrorItemNotFoundMessageNotFound404
ErrorInvalidIdMalformedInvalidMessageId400
ErrorAccessDeniedAccessDenied403
ErrorQuotaExceededQuotaExceeded429
ErrorExecuteSearchStaleDataSearchCursorExpired400
ErrorMailboxNotEnabledForRESTAPIMailboxNotEnabled403
ErrorInvalidRecipientsInvalidRecipients400
ErrorMessageSizeExceededMessageTooLarge413
ErrorSendAsDeniedSendAsDenied403

Rate-limited Gmail and Graph requests are retried by EmailEngine before the error is reported, so a 429 from either provider means the retries were exhausted.

Provider-Specific Errors

Errors from mail servers and OAuth2 providers. These do not come back from API calls; they arrive through webhooks and the account's lastError.

IMAP Errors

Common IMAP server responses:

ResponseMeaningSolution
NOCommand failedCheck credentials, permissions
BADInvalid commandReport to support (possible bug)
BYEServer closing connectionReconnect, check server status
NO [AUTHENTICATIONFAILED]Invalid credentialsUpdate password
NO [LIMIT]Rate limit exceededWait and retry
NO [OVERQUOTA]Mailbox quota exceededFree up space

The IMAP server's own response is recorded as the account's lastError, sent in the authenticationError and connectError webhooks as data.response and data.serverResponseCode, and written to the per-account log. An API call made while the account is in that state answers 503 with AuthenticationFails or ConnectionError.


SMTP Errors

An SMTP rejection is not returned from the submit call, because submission is queued: the call answers 200 with a queueId and the rejection arrives later as a messageDeliveryError webhook (one per failed attempt) or a messageFailed webhook (when the attempts are exhausted), carrying smtpResponse and smtpResponseCode.

4xx Temporary Errors

CodeMeaningAction
421Service not availableRetry later
450Mailbox busyRetry later
451Server errorRetry later
452Insufficient storageRetry later or reduce size

5xx Permanent Errors

CodeMeaningAction
550Mailbox not foundVerify recipient address
551User not localCheck recipient domain
552Storage exceededReduce message size
553Mailbox name invalidFix recipient address
554Transaction failedCheck message content/format

Common SMTP responses:

550 5.1.1 <user@example.com>: Recipient address rejected: User unknown

Verify the address exists.

550 5.7.1 Relaying denied

Check SMTP authentication and the account's sending permissions.

552 5.2.2 Mailbox full

The recipient needs to free up space; retry later.

554 5.7.1 Message rejected as spam

Review the message content and the sender's reputation.


OAuth2 Errors

Errors from Google and Microsoft token endpoints. They are reported in the authenticationError webhook (data.tokenRequest carries the request details) and in the account's lastError.

ErrorMeaningSolution
invalid_grantThe refresh token is expired or revokedRe-authorize the account
invalid_clientThe OAuth2 client ID or secret is wrongFix the credentials in the OAuth2 application
redirect_uri_mismatchThe redirect URI registered with the provider does not match EmailEngine'sRegister serviceUrl + /oauth with the provider, with the same protocol and host
insufficient_scopeThe grant lacks a scope the operation needsRe-authorize with the required scopes
{
"error": "invalid_grant",
"error_description": "Token has been expired or revoked"
}

A refresh token that keeps failing for longer than EENGINE_MAX_IMAP_AUTH_FAILURE_TIME switches syncing off for the account; see Max IMAP auth failure time.


Error Handling Best Practices

Retry Logic

Retry transient failures with exponential backoff:

async function makeRequestWithRetry(url, options, maxRetries = 3) {
const retriableStatusCodes = [429, 500, 503, 504];

for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, options);

if (response.ok) {
return response;
}

const data = await response.json();

// Do not retry client errors, except the rate limit
if (response.status >= 400 && response.status < 500 &&
response.status !== 429) {
throw new Error(data.message);
}

if (retriableStatusCodes.includes(response.status)) {
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}

throw new Error(data.message);

} catch (error) {
if (attempt === maxRetries - 1) {
throw error;
}
}
}
}

Error Categorization

Check the code before the status range: an account that is failing authentication is a 503, but retrying will not fix it, and only new credentials will.

function categorizeError(statusCode, errorCode) {
if (errorCode === 'AuthenticationFails' || errorCode === 'NotSyncing') {
return 'AUTH_ERROR';
}

if (statusCode === 429) {
return 'RATE_LIMIT';
}

if (statusCode === 503 || statusCode === 504) {
return 'TEMPORARY';
}

if (statusCode >= 400 && statusCode < 500) {
return 'CLIENT_ERROR';
}

if (statusCode >= 500) {
return 'SERVER_ERROR';
}

return 'UNKNOWN';
}

async function handleError(error, context) {
const category = categorizeError(error.statusCode, error.code);

switch (category) {
case 'CLIENT_ERROR':
// The request is wrong; retrying will not help
console.error('Client error:', error);
await alertDevelopers(error);
break;

case 'RATE_LIMIT':
await sleep(error.ttl * 1000);
return retryRequest(context);

case 'TEMPORARY':
case 'SERVER_ERROR':
return retryWithBackoff(context);

case 'AUTH_ERROR':
// Ask the user for new credentials or a fresh OAuth2 grant
await requestReauthorization(context.account);
break;

default:
console.error('Unknown error:', error);
}
}

User-Friendly Messages

Map codes to messages for end users:

const ERROR_MESSAGES = {
'AuthenticationFails': 'Email login failed. Please check your password or sign in again.',
'NotSyncing': 'Syncing is switched off for this mailbox. Sign in again to resume it.',
'MessageNotFound': 'Email message not found. It may have been deleted.',
'ConnectionError': 'Unable to connect to the email server. Please try again later.',
'SMTPUnavailable': 'This mailbox is not set up for sending.'
};

function getUserMessage(errorCode) {
return ERROR_MESSAGES[errorCode] ||
'An unexpected error occurred. Please try again.';
}

Debugging Errors

Enable Debug Logging

EENGINE_LOG_LEVEL=trace
EENGINE_LOG_RAW=true

EENGINE_LOG_RAW writes unmasked credentials to the log; use it only while debugging.

Check Logs

Docker:

docker logs -f emailengine

SystemD:

journalctl -u emailengine -f

Common Debug Steps

  1. Read the full error response:

    curl -X GET https://emailengine.example.com/v1/account/test \
    -H "Authorization: Bearer TOKEN" \
    -v
  2. Check the account's state and last error:

    curl https://emailengine.example.com/v1/account/test \
    -H "Authorization: Bearer TOKEN"

    state, lastError and authFailureDisabledAt say why an account answers 503.

  3. Verify the mail server is reachable:

    openssl s_client -connect imap.example.com:993
  4. Check Redis:

    redis-cli ping

See Also