> ## Documentation Index
> Fetch the complete documentation index at: https://developers.telnyx.com/llms.txt
> Use this file to discover all available pages before exploring further.

# emailChannel()

> **emailChannel**(`options?`): [`EmailChannelAdapter`](https://developers.telnyx.com/docs/agent-sdk/harness-sdk/channels/email-adapter#emailchanneladapter)

An email channel adapter: `inbound(payload)` normalises a received mail,
`send(request)` replies in its thread through the inbox's reply action.

**Parameters**

| Parameter | Type |
| - | - |
| `options` | [`EmailChannelOptions`](https://developers.telnyx.com/docs/agent-sdk/harness-sdk/channels/email-adapter#emailchanneloptions) |

**Returns**
[`EmailChannelAdapter`](https://developers.telnyx.com/docs/agent-sdk/harness-sdk/channels/email-adapter#emailchanneladapter)

## Over the Email API

`emailChannel()` is exported from `@telnyx/agent-harness/channels`, from 0.1.0-beta.3. The adapter it returns is a [`ChannelAdapter`](https://developers.telnyx.com/docs/agent-sdk/harness-sdk/channels/router#channeladapter) with `channel: "email"`, for [`createChannelRouter()`](https://developers.telnyx.com/docs/agent-sdk/harness-sdk/channels/router). It handles the `email.received` webhook of an [inbox](https://developers.telnyx.com/docs/messaging/email/inboxes.md). The host verifies the webhook's signature and passes its `data.payload` to `inbound()`.

```ts theme={null}
import { createChannelRouter, InMemoryChannelLedger } from "@telnyx/agent-harness";
import { emailChannel } from "@telnyx/agent-harness/channels";

const email = emailChannel({
  transport: env.TELNYX, // the Telnyx client; paths are relative to /v2
  inboxId: "inbox-id",
  onEvent: (event) => console.log(JSON.stringify(event)),
});

const router = createChannelRouter({
  adapters: [email],
  ledger: new InMemoryChannelLedger(),
  policy: { authorize: async () => true },
  accept: async (inbound) => ({ runId: await startRun(inbound) }),
});

// The webhook handler, after verifying the signature:
const inbound = await email.inbound(webhook.data.payload);
if (!inbound) return; // left alone: an event says why
const { runId } = await router.receive(inbound);

// Later, with the run's reply:
await router.reply({
  runId,
  serviceAccountId,
  idempotencyKey: runId,
  content: { text: "Sure, done.", messageId: inbound.content.messageId },
});
```

* **Inbox.** The inbox is the payload's `inbox_id`. Without one, the adapter looks the recipient up in the account's inbox list (`GET /email_inboxes`), on `domainId` when it is set, and keeps the list per transport for 60 seconds. A mail for another inbox than `inboxId`, or to another address than `address`, is left alone.
* **Matching.** The reply target is the inbox's record of the mail. The adapter lists the inbox's newest messages from the sender (`filter[from]`, `page[size]` 10) and takes the inbound one the one whose `id` or `message_id` equals the envelope's `id` or its RFC Message-ID (the live payload's `message_id_header`). A Message-ID is compared without its angle brackets. The adapter never matches by subject or recency.
* **Text.** The text given to the model is the record's `reply_text`, else the plain-text body at `text_body_url` with its quoted history dropped, else the message read back from its thread. A body is fetched only over `https` from a host in `bodyHosts`, through the transport first and then with plain `fetch`. Redirects are never followed.
* **Conversation.** `conversationId` is `email-<thread id>`, else `email-<sender>`. `externalMessageId` is the envelope's `id`, which a redelivery repeats, so the router's ledger treats a redelivery as a duplicate.
* **Reply.** `send()` posts `{ text }` to `POST /email_inboxes/{inbox}/messages/{message}/actions/reply` with the request's idempotency key as the `Idempotency-Key` header. The platform addresses and threads the reply.

## Unavailable mail

`inbound()` throws [`EmailInboundUnavailableError`](#emailinboundunavailableerror) when the inbox has no record that matches the mail, and when a read it needs fails (the inbox list, the inbox, the body or the thread) and no other source has the text. The host answers the webhook with a non-2xx status, such as `503`, so the platform delivers it again. A mail that is not this adapter's, cannot be placed in an inbox or has no text returns `undefined`; a redelivery would end the same way, so the host acknowledges it.

> [!NOTE]
>
> The platform redelivers a failed `email.received` webhook once, about one second later.

## EmailChannelOptions

**Properties**
**address?**

> `readonly` `optional` **address?**: [`EmailAddressScope`](https://developers.telnyx.com/docs/agent-sdk/harness-sdk/channels/email-adapter#emailaddressscope)

The address this adapter answers at, for a mail whose inbox is not yet known by id: the inbox is looked up for that recipient.

**bodyHosts?**

> `readonly` `optional` **bodyHosts?**: readonly `string`\[]

Hosts an offloaded body may be fetched from (the host itself or a subdomain, over https). Default `EMAIL_BODY_HOSTS`.

**domainId?**

> `readonly` `optional` **domainId?**: `string`

The inbound domain, so the recipient lookup never takes an inbox of the same address on another domain.

**fetch?**

> `readonly` `optional` **fetch?**: (`url`, `init?`) => `Promise`\<`Response`>

Plain fetch for a public body link, and for an adapter without a transport. Default: the global `fetch`. Redirects are never followed.

**Parameters**

| Parameter | Type |
| - | - |
| `url` | `string` |
| `init?` | `RequestInit` |

**Returns**
`Promise`\<`Response`>

**inboxId?**

> `readonly` `optional` **inboxId?**: `string`

The inbox this adapter answers for and replies from. A mail to another inbox is left alone.

**now?**

> `readonly` `optional` **now?**: () => `number`

The clock for `receivedAt`. Default `Date.now`.

**Returns**
`number`

**onEvent?**

> `readonly` `optional` **onEvent?**: (`event`) => `void`

Told what happened to each mail.

**Parameters**

| Parameter | Type |
| - | - |
| `event` | [`EmailChannelEvent`](https://developers.telnyx.com/docs/agent-sdk/harness-sdk/channels/email-adapter#emailchannelevent) |

**Returns**
`void`

**transport?**

> `readonly` `optional` **transport?**: [`EmailTransport`](https://developers.telnyx.com/docs/agent-sdk/harness-sdk/channels/email-adapter#emailtransport)

The API. Without one, an offloaded body is fetched with plain `fetch`,
nothing is read back from the inbox, and `send` refuses.

## EmailAddressScope

The address an adapter answers at: the local part, and the domain when one is named (both compared case-insensitively).

**Properties**
**domain?**

> `readonly` `optional` **domain?**: `string`

**username**

> `readonly` **username**: `string`

## EmailTransport

The Telnyx API, as the adapter calls it: paths are relative to `/v2`; an absolute URL goes as given. A Telnyx client is one.

**Methods**
**get()**

> **get**\<`T`>(`path`, `options?`): `Promise`\<`T`>

**Type Parameters**

| Type Parameter | Default type |
| - | - |
| `T` | `unknown` |

**Parameters**

| Parameter | Type |
| - | - |
| `path` | `string` |
| `options?` | \{ `fetchOptions?`: \{ `redirect?`: `"manual"` \| `"error"` \| `"follow"`; }; `query?`: `Readonly`\<`Record`\<`string`, `unknown`>>; } |
| `options.fetchOptions?` | \{ `redirect?`: `"manual"` \| `"error"` \| `"follow"`; } |
| `options.fetchOptions.redirect?` | `"manual"` \| `"error"` \| `"follow"` |
| `options.query?` | `Readonly`\<`Record`\<`string`, `unknown`>> |

**Returns**
`Promise`\<`T`>

**post()**

> **post**\<`T`>(`path`, `options?`): `Promise`\<`T`>

**Type Parameters**

| Type Parameter | Default type |
| - | - |
| `T` | `unknown` |

**Parameters**

| Parameter | Type |
| - | - |
| `path` | `string` |
| `options?` | \{ `body?`: `unknown`; `headers?`: `Readonly`\<`Record`\<`string`, `string`>>; } |
| `options.body?` | `unknown` |
| `options.headers?` | `Readonly`\<`Record`\<`string`, `string`>> |

**Returns**
`Promise`\<`T`>

## EmailChannelAdapter

**Properties**
**channel**

> `readonly` **channel**: `"email"`

**Methods**
**inbound()**

> **inbound**(`payload`): `Promise`\<[`EmailChannelInbound`](https://developers.telnyx.com/docs/agent-sdk/harness-sdk/channels/email-adapter#emailchannelinbound) | `undefined`>

Normalise one `email.received` payload (the webhook's `data.payload`).
The envelope's id is the message id a redelivery repeats; the thread is
the conversation (`email-<thread id>`) when the envelope names it or the
message is found in the inbox by its id, else the sender's (`email-<sender>`),
so a redelivery lands in the same conversation whatever the inbox holds
by then. The text is the platform's `reply_text`, else the plain-text body
at `text_body_url` with its quoted tail dropped, else the message read
back from its thread. Nothing when the mail is not this adapter's, cannot
be placed in an inbox, or has no text: an event says why. Throws
`EmailInboundUnavailableError` when a read it needed failed and may pass.

**Parameters**

| Parameter | Type |
| - | - |
| `payload` | `Readonly`\<`Record`\<`string`, `unknown`>> |

**Returns**
`Promise`\<[`EmailChannelInbound`](https://developers.telnyx.com/docs/agent-sdk/harness-sdk/channels/email-adapter#emailchannelinbound) | `undefined`>

**send()**

> **send**(`request`): `Promise`\<\{ `providerRequestId`: `string`; }>

Reply in thread. `content` is an `EmailReplyContent`; the idempotency key goes with the request. `providerRequestId` is the API's id of the reply, else `reply:<idempotency key>`.

**Parameters**

| Parameter | Type |
| - | - |
| `request` | [`ChannelSendRequest`](https://developers.telnyx.com/docs/agent-sdk/harness-sdk/channels/router#channelsendrequest) |

**Returns**
`Promise`\<\{ `providerRequestId`: `string`; }>

## EmailChannelInbound

**Properties**
**channel**

> `readonly` **channel**: `"email"`

**content**

> `readonly` **content**: [`EmailInboundContent`](https://developers.telnyx.com/docs/agent-sdk/harness-sdk/channels/email-adapter#emailinboundcontent)

**conversationId**

> `readonly` **conversationId**: `string`

**externalMessageId**

> `readonly` **externalMessageId**: `string`

**receivedAt**

> `readonly` **receivedAt**: `number`

## EmailInboundContent

> **EmailInboundContent** = `object`

What the model is given of a mail: the new text, and where it came from and goes back to.

**Properties**
**from**

> `readonly` **from**: `string`

**inboxId**

> `readonly` **inboxId**: `string`

The inbox the mail arrived in; the reply goes out from it.

**messageId**

> `readonly` **messageId**: `string`

The inbox message the reply answers; the platform threads and addresses it.

**subject?**

> `readonly` `optional` **subject?**: `string`

**text**

> `readonly` **text**: `string`

The new text of the mail, its quoted history and signature dropped.

**threadId?**

> `readonly` `optional` **threadId?**: `string`

**to**

> `readonly` **to**: readonly `string`\[]

## EmailReplyContent

> **EmailReplyContent** = `object`

A reply: the text, and the message it answers. The inbox is the adapter's unless named.

**Properties**
**inboxId?**

> `readonly` `optional` **inboxId?**: `string`

**messageId**

> `readonly` **messageId**: `string`

**text**

> `readonly` **text**: `string`

## EmailChannelEvent

> **EmailChannelEvent** = `Readonly`\<\{ `type`: [`EmailChannelEventType`](https://developers.telnyx.com/docs/agent-sdk/harness-sdk/channels/email-adapter#emailchanneleventtype); } & `Record`\<`string`, `unknown`>>

Something the adapter noticed while handling a mail: why one was left
alone, what it read, what failed. `type` says which; the other fields are
its details. Hosts log them.

## EmailChannelEventType

> **EmailChannelEventType** = `"unrecognised payload"` | `"not this agent's inbox"` | `"not this agent's address"` | `"no inbox for the recipient"` | `"inbox list failed"` | `"payload"` | `"message not in inbox yet"` | `"inbox read failed"` | `"body url skipped"` | `"body redirect skipped"` | `"body fetch failed"` | `"thread read failed"` | `"no text"`

## EmailInboundUnavailableError

The mail could not be normalised now, for a reason that may pass: the
API refused or did not answer a read it needed (the inbox list, the inbox,
the body, the thread). The host should answer the webhook with an error so
the platform delivers it again; a mail left alone for good is not this.

**Constructors**
**Constructor**

> **new EmailInboundUnavailableError**(`message`, `options?`): `EmailInboundUnavailableError`

**Parameters**

| Parameter | Type |
| - | - |
| `message` | `string` |
| `options?` | \{ `cause?`: `unknown`; } |
| `options.cause?` | `unknown` |

**Returns**
`EmailInboundUnavailableError`

**Properties**
**retryable**

> `readonly` **retryable**: `true` = `true`

## emailConversation()

> **emailConversation**(`mail`): `string`

The conversation an email thread is: one per thread, so a reply in the thread continues it; else one per sender.

**Parameters**

| Parameter | Type |
| - | - |
| `mail` | `Readonly`\<\{ `from`: `string`; `threadId?`: `string`; }> |

**Returns**
`string`

## stripQuotedEmail()

> **stripQuotedEmail**(`text`): `string` | `undefined`

The new text of a reply, without the quoted history under it: everything
from the first "On … wrote:" line or the first quoted (`>`) line on is dropped,
and so is a signature after a `-- ` line. Conservative: a mail that does not
look quoted is returned whole.

**Parameters**

| Parameter | Type |
| - | - |
| `text` | `string` \| `undefined` |

**Returns**
`string` | `undefined`

## isEmailBodyHost()

> **isEmailBodyHost**(`url`, `hosts?`): `boolean`

Whether a body URL is on one of the allowed hosts (the host itself or a subdomain), over https.

**Parameters**

| Parameter | Type | Default value |
| - | - | - |
| `url` | `string` | `undefined` |
| `hosts` | readonly `string`\[] | `EMAIL_BODY_HOSTS` |

**Returns**
`boolean`

## EMAIL\_BODY\_HOSTS

> `const` **EMAIL\_BODY\_HOSTS**: readonly `string`\[]

Where an offloaded mail body may be fetched from by default: Telnyx's own hosts, never an address the payload could point anywhere.

The value is `["telnyx.com", "telnyxstorage.com"]`.

## redactUrl()

> **redactUrl**(`url`): `string`

A URL as it may be logged: origin and path only. A body link is often
signed, and its query (or userinfo, or fragment) can grant access to the
mail it points at.

**Parameters**

| Parameter | Type |
| - | - |
| `url` | `string` |

**Returns**
`string`

## forgetEmailInboxCache()

> **forgetEmailInboxCache**(): `void`

Forget the cached inbox lists (tests, or after an inbox was made).

**Returns**
`void`
