> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# SMTP relay recipes: full request–response–error loop

> A worked HTTP sequence for email over the SMTP relay capability — a request with base64 attachments, the 202-accepted envelope, and the INVALID_RECIPIENT / CHANNEL_NOT_CONFIGURED / RATE_LIMITED branches, with Node, Python, Go, and PHP SDK snippets.

# SMTP relay recipes: send email over HTTP

The **SMTP relay** capability is a second ingress path for the email channel: your client submits an RFC 5322 message over the authenticated SMTP edge described on [Channels → SMTP relay](/channels/email-smtp-relay), and the relay maps the session onto the same HTTP payload `POST /api/v1/messages/email` produces. This guide walks the equivalent **HTTP flow end to end** — the request body with an attachment, the response a happy path returns, the error branches worth coding against, and each SDK's call — so you can model or verify client behaviour without touching an SMTP socket.

Use this flow before writing SMTP client code, or when you want the SMTP relay's pipeline guarantees (suppression, verified-sender enforcement, shared rate pool) while keeping payloads and error codes fully visible.

## 1. Send with an attachment

`POST /api/v1/messages/email` accepts either `html` or `text` (at least one is required) plus an optional `attachments` array — each entry carries `filename`, `content_type`, and either a `url` (an Orbit Files API URL) or inline base64 `content`.

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/messages/email \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042-receipt-v1" \
  -d '{
    "to": "customer@example.com",
    "from": "Devotel Orbit <receipts@mail.example.com>",
    "subject": "Your receipt",
    "html": "<p>Thanks for your order — receipt attached.</p>",
    "text": "Thanks for your order — receipt attached.",
    "attachments": [
      {
        "filename": "receipt-1042.pdf",
        "content_type": "application/pdf",
        "content": "JVBERi0xLjQKJ2NhbXBsaW5nIGJ5dGVzJwo="
      }
    ],
    "metadata": { "order_id": "1042" }
  }'
```

Two limits to design around: attachments cap at **10 MB per file, 25 MB per email, 20 files per send**, and `content_type` must be on the MIME allowlist — see [Email attachments](/guides/email-attachments) for the full list and for when to use a Files URL instead of inline base64. The `Idempotency-Key` header makes client retries safe: the same key replays the original response instead of double-sending.

The same body shape is exactly what the SMTP relay produces after mapping an RFC 5322 submission — envelope `RCPT TO` recipients become `to`, the `From:` header becomes `from`, and the HTML part (preferred over plain text) becomes `html`.

## 2. The 202 response envelope

A successful send returns `202` with the message record under `data` plus a `meta` block that also carries the request id you saw in the `X-Request-Id` response header:

```json theme={null}
HTTP/1.1 202 Accepted
X-Request-Id: req_9f3d2c7b1a54
Idempotency-Replay: false

{
  "data": {
    "id": "msg_01J8ZK4M6BQRF3WT6XH2QM2Pkm",
    "status": "queued",
    "channel": "email",
    "from": "receipts@mail.example.com",
    "to": "customer@example.com",
    "body": "Thanks for your order — receipt attached.",
    "price": "0.0012",
    "currency": "USD",
    "external_id": null,
    "created_at": "2026-09-06T14:22:07.000Z"
  },
  "meta": {
    "request_id": "req_9f3d2c7b1a54",
    "timestamp": "2026-09-06T14:22:07.104Z"
  }
}
```

`status` at acceptance time is `queued` (or `scheduled` when you pass `scheduled_at`); poll `GET /api/v1/messages/{id}` or consume the message-status webhooks to follow it to `sent`/`delivered`. `price` is the credits cost of the send.

## 3. Error branches to handle

Every error returns the standard envelope `{ "error": { "code", "message", "status" }, "meta": { ... } }`. Three `code` values account for most production failures on this path:

### INVALID\_RECIPIENT — 422

The recipient address failed the per-recipient gate (malformed mailbox, list-hygiene check, or suppression). The message is refused before queueing and you are not charged.

```json theme={null}
{
  "error": {
    "code": "INVALID_RECIPIENT",
    "message": "Recipient address failed validation: user@@example",
    "status": 422,
    "details": { "to": "user@@example" }
  },
  "meta": {
    "request_id": "req_2e8d1c",
    "timestamp": "2026-09-06T14:23:01.337Z"
  }
}
```

**Fix:** validate recipient addresses before sending, or scrub the list with the [address-validation endpoint](/api-reference/endpoints/email-validate). A suppressed address (hard bounce, complaint, or unsubscribe) is deliberately refused — re-engage it only after removing the suppression entry (see [Bounce and Complaint Handling](/channels/email#bounce-and-complaint-handling)).

### CHANNEL\_NOT\_CONFIGURED — 503

The tenant has no working email provider: a sender domain was never verified, the provider credentials were revoked, or the channel bootstrap skipped the lane. Nothing is queued.

```json theme={null}
{
  "error": {
    "code": "CHANNEL_NOT_CONFIGURED",
    "message": "Email channel has no provider configured for this tenant",
    "status": 503,
    "details": { "channel": "email" }
  },
  "meta": {
    "request_id": "req_4ad0bb",
    "timestamp": "2026-09-06T14:24:10.009Z"
  }
}
```

**Fix:** complete sender and domain setup under **Channels → Email** (the same verified domain governs the SMTP relay — a relayed `554` after `DATA` is the SMTP-session equivalent of this branch). Surface `details.provider_message` if present for the provider-side detail.

### RATE\_LIMITED — 429

The shared email rate pool (default 200 messages per minute per tenant, shared with SMTP-relay ingress and burst fanout) is exhausted. The response usually carries a `retry_after` hint in `details`.

```json theme={null}
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Email rate pool exceeded for this tenant",
    "status": 429,
    "details": { "retry_after": 12, "pool": "200/min" }
  },
  "meta": {
    "request_id": "req_78cd01",
    "timestamp": "2026-09-06T14:25:33.811Z"
  }
}
```

**Fix:** back off using the returned `retry_after` window, then retry. Because HTTP and SMTP relay traffic draw from one pool, moving sends between the two paths does not dodge the limit — see [Rate limits](/guides/rate-limits).

Other codes you may see on this path: `VALIDATION_ERROR` (bad payload shape or attachment limit), `QUOTA_EXCEEDED` (billing cap), and `MESSAGE_SEND_FAILED` (provider-side failure — retry once, then alert). The full catalog lives on the [Email channel page](/channels/email#error-catalog).

## 4. SDK snippets

Each SDK wraps `POST /api/v1/messages/email`; the examples below send the same attachment email and read the same `code` branches. Attachments with URL `url` fields are supported on all four SDKs; inline base64 `content` follows the same body shape shown in the curl above.

### Node.js — `@devotel-orbit/node`

```typescript theme={null}
import { Devotel } from '@devotel-orbit/node';

const orbit = new Devotel({ apiKey: process.env.ORBIT_API_KEY });

try {
  const res = await orbit.messages.sendEmail({
    to: 'customer@example.com',
    from: 'Devotel Orbit <receipts@mail.example.com>',
    subject: 'Your receipt',
    html: '<p>Thanks for your order — receipt attached.</p>',
    attachments: [
      {
        filename: 'receipt-1042.pdf',
        content_type: 'application/pdf',
        content: 'JVBERi0xLjQKJ2NhbXBsaW5nIGJ5dGVzJwo=',
      },
    ],
  });
  console.log(res.data.id, res.data.status); // msg_…, 'queued'
} catch (err: any) {
  const code = err?.code ?? err?.error?.code;
  if (code === 'INVALID_RECIPIENT') {
    // scrub the address before resending
  } else if (code === 'CHANNEL_NOT_CONFIGURED') {
    // finish sender/domain setup under Channels → Email
  } else if (code === 'RATE_LIMITED') {
    // back off and retry using err.error.details.retry_after
  }
  throw err;
}
```

### Python — `orbit_sdk`

```python theme={null}
from orbit_sdk import OrbitClient

client = OrbitClient.from_api_key("dv_live_sk_...")
try:
    res = client.messages.send_email(
        to="customer@example.com",
        subject="Your receipt",
        body="<p>Thanks for your order — receipt attached.</p>",
    )
    print(res["data"]["id"], res["data"]["status"])
except Exception as err:
    payload = getattr(err, "response", {}) or getattr(err, "args", [{}])[0]
    code = (payload.get("error") or {}).get("code") if isinstance(payload, dict) else None
    if code == "INVALID_RECIPIENT":
        pass  # scrub the address
    elif code == "CHANNEL_NOT_CONFIGURED":
        pass  # finish email setup under Channels → Email
    elif code == "RATE_LIMITED":
        pass  # back off and retry
    raise
```

### Go — `devotel/orbit`

```go theme={null}
client, err := orbit.NewClient(os.Getenv("ORBIT_API_KEY"))
if err != nil {
    log.Fatal(err)
}

res, err := client.Messages().SendEmail(context.Background(),
    orbit.SendEmailInput{
        To:      "customer@example.com",
        Subject: "Your receipt",
        Body:    "<p>Thanks for your order — receipt attached.</p>",
    })
if err != nil {
    var apiErr *orbit.APIError
    if errors.As(err, &apiErr) {
        switch apiErr.Code {
        case "INVALID_RECIPIENT":
            // scrub the address before resending
        case "CHANNEL_NOT_CONFIGURED":
            // finish email setup under Channels → Email
        case "RATE_LIMITED":
            // back off and retry
        }
    }
    return
}
fmt.Println(res.Data.ID, res.Data.Status) // msg_…, queued
```

### PHP — `devotel/orbit`

```php theme={null}
$client = OrbitClient::fromApiKey('dv_live_sk_...');

try {
    $res = $client->messages->sendEmail(
        'customer@example.com',
        'Your receipt',
        '<p>Thanks for your order — receipt attached.</p>',
    );
    echo $res['data']['id'] . ' ' . $res['data']['status'];
} catch (\Exception $err) {
    $code = $err->code ?? null;
    match ($code) {
        'INVALID_RECIPIENT' => null,       // scrub the address
        'CHANNEL_NOT_CONFIGURED' => null,  // finish email setup
        'RATE_LIMITED' => null,            // back off and retry
        default => null,
    };
    throw $err;
}
```

The `retry_after` value in `details` is your best backoff hint — use it rather than a fixed sleep.

## Where it connects back to SMTP

Once the client works over HTTP, point it at the SMTP edge only if HTTP is not practical — see **Connection profile** on the [SMTP relay channel page](/channels/email-smtp-relay) for ports and the `apikey` sentinel. The same API key gates both paths, the same sender domain gates them both, and suppression drawn from either path applies to the other.
