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, path} entry per rejected input. path is the dotted path of a nested field, such as to.0.address, and the same as key for a top-level one (since v2.79.8)
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
infoobjectExtra context a few failures attach, most often info.response with the mail server's own answer when it refused a mailbox operation
detailsobject or arrayExtra context a few routes attach, such as delivery test results
existingAccountstringAccountAlreadyExists only: the account the OAuth2 user is already bound to
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", "path": "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.

Mail server refused the operation:

When an IMAP server answers NO to a mailbox operation (creating, renaming or deleting a folder, reading the quota), the request was well formed and the server refused it, and the answer is a 400 with no fields. code is the IMAP response code the server sent, such as ALREADYEXISTS, NONEXISTENT, CANNOT or LIMIT, or DeleteFailed / RenameFailed when the server sent none, and info.response carries the server's text:

{
"statusCode": 400,
"error": "Bad Request",
"message": "Delete failed",
"code": "CANNOT",
"info": {
"response": "Mailbox has inferior hierarchical names"
}
}

Other 400 codes:

CodeWhen
InvalidIdA message, attachment or text identifier that cannot be decoded. Since v2.79.8; before that such an identifier answered 500
InvalidCursorType, InvalidCursorValueA paging cursor issued for a different account type, or one that cannot be parsed
UnsupportedSearchTermGmail API and MS Graph accounts: a search field the provider cannot filter on
UnsupportedOperationAn operation this account type has no equivalent for, such as replacing the whole label set on a Gmail API account, or submitting a stored draft on an account type that cannot
MessageNotDraftPOST /v1/account/{account}/message/{message}/submit on a message that is not a draft
ReferenceNotSupportedA reply or forward reference on a send-only account
InvalidInputGmail API accounts: a page number instead of a cursor
ForceRequiredDELETE /v1/templates/account/{account} without force=true

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
A token with restricted permissions can not change ... (or read ...)GET or POST /v1/settings named a privileged setting with a token that has a permissions record. The message lists the keys. Since v2.80.1
The new token would be less restricted than the token creating it: ...POST /v1/tokens asked for an address or referrer allowlist, a rate limit or an expiry wider than the calling token's own. Carries the code MintWidensRestrictions. Since v2.82.0
OAuth2 request failedGmail API and MS Graph accounts: the provider answered 401 to a request made with a freshly renewed access token. Carries the code OAuthTokenRejected
{
"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. The exact message of a 403 is the only thing to match on apart from the two codes above; the table shows the messages as the source produces them.


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"
}

So is a missing SMTP gateway (Gateway "x" was not found). A missing message, folder, template, webhook route or OAuth2 application carries a code:

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

Two more 404 codes come from a submission rather than from the path: ReferenceNotFound when the message a reply or forward refers to no longer exists, and TemplateNotFound when template names a template that does not exist or belongs to another account. SMTPUnavailable is also a 404: the account has no SMTP or OAuth2 configuration to send with. On MS Graph accounts, a delete that cannot find the Deleted Items folder answers TrashNotFound.


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. Three configuration faults surface this way with a code and no better status: AppNotFound (the OAuth2 application the account references was deleted or disabled), InvalidBaseScopes (that application is registered for the API rather than for IMAP) and MissingServiceURLSetup (POST /v1/authentication/form while serviceUrl is unset).


502 Bad Gateway​

The IMAP server rejected an APPEND while POST /v1/account/{account}/message was uploading a message, after the target folder was confirmed to exist. The body carries the code UploadFail and the IMAP client's message for the failed command. A folder that does not exist is a 404 instead.


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
WorkerNotAvailableanyNo worker thread holds the account (No active handler for requested account), or the one that did terminated mid-request. No state field. Retry

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, by status:

CodeStatusWhen
AccountAlreadyExists400The OAuth2 user is already bound to another account under the same OAuth2 application; the body names it in existingAccount
InvalidId400A message, attachment or text identifier that cannot be decoded (since v2.79.8)
InvalidCursorType, InvalidCursorValue400A paging cursor from another account type, or one that cannot be parsed
UnsupportedSearchTerm400Gmail API or MS Graph cannot filter on a field the search named
UnsupportedOperation400The account type has no such operation
MessageNotDraft400The message submitted from the mailbox is not a draft
ReferenceNotSupported400A reply or forward reference on a send-only account
InvalidInput400Gmail API accounts: a page number instead of a cursor
ForceRequired400Flushing an account's templates without force=true
ALREADYEXISTS, NONEXISTENT, CANNOT, LIMIT and other IMAP response codes, DeleteFailed, RenameFailed400The IMAP server refused a mailbox operation; info.response carries its text
MintWidensRestrictions403POST /v1/tokens asked for restrictions wider than the calling token's (since v2.82.0)
OAuthTokenRejected403The provider rejected a freshly renewed access token
AccountNotFound404Export endpoints only: the account does not exist
MessageNotFound404The message ID does not exist in the mailbox
FolderNotFound404The mailbox path does not exist
ReferenceNotFound404The message a reply or forward refers to does not exist
TemplateNotFound404A submission names a template that does not exist or belongs to another account
TrashNotFound404MS Graph accounts: the Deleted Items folder cannot be resolved
NotFound404The template, webhook route or OAuth2 application does not exist
SMTPUnavailable404The account has no SMTP or OAuth2 configuration to send with
MissingServerExtension422The IMAP server lacks an extension the operation needs
AppNotFound, InvalidBaseScopes, MissingServiceURLSetup500A configuration fault: a missing or disabled OAuth2 application, one registered for the wrong connection type, or no serviceUrl for a hosted form
UploadFail502The IMAP server rejected the APPEND of an uploaded message
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
WorkerNotAvailable503No worker thread holds the account, or it terminated mid-request
Timeout504A worker thread did not answer within EENGINE_TIMEOUT

A missing account or SMTP gateway is a plain 404 without a code, and a 401 never carries one.

Provider-Mapped Codes​

For 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 errors pass through with their own status.

Gmail API:

Gmail API errors are not translated to EmailEngine codes. The HTTP status Google answered with becomes the API status, and the body carries EmailEngine's OAuth2 request failed message and, unless the failure matched a known OAuth2 condition, no code: a 400 for a request Gmail rejected, a 403 for a scope or policy refusal, a 500 or 503 from Google's side. Two cases are recognized: a 404 from Gmail answers MessageNotFound, and a 401 that survives a token renewal answers 403 with the code OAuthTokenRejected. Rate limits (429, or a 403 carrying the rateLimitExceeded or userRateLimitExceeded reason) are retried first, honoring Retry-After, so a 429 from the API means the retries were exhausted.

Microsoft Graph:

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

Rate-limited Graph requests are retried by EmailEngine before the error is reported, like the Gmail ones, 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​