Guides

Email channel

AdminUpdated Sep 19, 2026

Email channel

A mailbox you already own — SMTP or a hosted provider for outbound, provider inbound-parse webhooks for replies. Chatly never runs an SMTP server.

Chatly does not run an SMTP server. Email is the channel where every shortcut bites you eventually — deliverability rules, bounces, DKIM, complaints — so instead of building yet another half-baked mail stack, we speak to the mailbox or the transactional provider you already trust.

Info — Why BYOK email

Your provider already owns your sending reputation, your DKIM keys, and your inbound parsing. We piggy-back on that. You keep your sender domains; we never re-deliver mail from our infrastructure.

The two halves are configured separately

This is the single most important thing to understand before you start, and the setup form does not spell it out:

  • Outbound is resolved from the channel config — an SMTP host, or an API key for one of the hosted providers listed below.

  • Inbound arrives only by a hosted provider POSTing a parsed message to one of our four receiver routes. Everything is matched back to your channel by the inbound address.

Warning — The mailbox provider selector does not fetch mail

The setup form asks for a Mailbox provider (gmail / outlook / imap) and, for generic IMAP, an imapHost and imapPort. Those two values are stored and nothing reads them — there is no IMAP poller in the product. Picking a mailbox provider configures your outbound credentials and nothing else. If you want replies to reach your inbox you must wire one of the inbound receivers below.

Create the channel

Channels → New → Email. The form collects:

Channel config (Email) — what the setup form writes

Name

Type

Description

address (required)

email

The address customers write to. This is the routing key for every inbound email — the receiver matches the envelope recipient against lower(config->>'address'), exact match only. Mail for anything else is not filed on this channel.

provider (required)

enum

gmail | outlook | imap. Selects which host fields the form shows.

imapHost

string

Collected for imap. Read by nothing today — see the callout above.

imapPort

number

Collected for imap. Read by nothing today. Default: 993.

smtpHost

string

Collected for imap. Its presence is what selects the SMTP outbound path, ahead of every HTTP provider.

smtpPort

number

Falls back to 587 when unset or unparseable. Implicit TLS is inferred when the port is 465. Default: 587.

username

string

Mailbox login. Used as the SMTP auth user, and as the last-resort From address.

password

password

Mailbox password or app password. Encrypted at rest.

postmarkUser + postmarkPass

string

Under Inbound relay credentials. The HTTP Basic pair the Postmark inbound webhook presents. The password half is encrypted at rest; the username half stays readable so you can check it against Postmark's console.

sendgridUser + sendgridPass

string

Under Inbound relay credentials. The HTTP Basic pair embedded in the SendGrid Inbound Parse destination URL.

mailgunApiKey

password

Under Inbound relay credentials. Verifies the HMAC Mailgun signs each forwarded message with. Doubles as the outbound Mailgun credential when paired with mailgunDomain.

sesSharedSecret

password

Under Inbound relay credentials. Optional second factor for SES — see the SES note below. Leave blank unless you also add the header to the SNS subscription.

Info — Relay credentials are a separate axis from the mailbox provider

Mailbox provider picks how we send. Inbound relay credentials configure how a hosted provider hands mail to us. A workspace can use both, and the fields are shown together rather than gated on the provider selector for exactly that reason.

POST /v1/channels — Bearer token

POST /v1/channels
Authorization: Bearer …
Idempotency-Key: 4f1c…
Content-Type: application/json

{
  "type": "email",
  "name": "Support inbox",
  "publicId": "acme-support-email",
  "config": {
    "address": "[email protected]",
    "provider": "imap",
    "smtpHost": "smtp.acme.com",
    "smtpPort": 587,
    "username": "[email protected]",
    "password": "…"
  }
}

Warning — publicId is required and you choose it

POST /v1/channels rejects a body without publicId. It must be unique across the deployment, is 1–64 characters, and cannot be changed later — external systems key off it.

Outbound

The sender resolves a route in this order:

  1. SMTP — if smtpHost is set, mail goes out over SMTP using smtpUser/smtpPass when present, otherwise username/password.

  2. A hosted HTTP provider — chosen by emailProvider, or inferred from whichever credential key is present.

  3. Postmark — the historical default, used when postmarkApiToken is set and nothing above matched.

Hosted-provider OUTBOUND credential keys (set via the API; the setup form offers only mailgunApiKey, which is shared with inbound)

Name

Type

Description

emailProvider

enum

Names the provider explicitly. Otherwise the first credential key found below wins.

postmarkApiToken

string

Postmark server API token.

sendgridApiKey

string

SendGrid API key.

mailgunApiKey + mailgunDomain

string

Both required — Mailgun's send endpoint is per-domain.

sesAccessKeyId + sesSecretAccessKey + sesRegion

string

All three required. Signed with SigV4.

mailjetApiKey + mailjetApiSecret

string

Mailjet authenticates with a key/secret pair.

socketlabsApiKey + socketlabsServerId

string

SocketLabs addresses the server by id.

scalewayApiKey + scalewayProjectId

string

Scaleway TEM is project-scoped.

Single-key providers

string

resendApiKey, sparkpostApiKey, brevoApiKey, mandrillApiKey, mailersendApiKey, smtp2goApiKey, elasticEmailApiKey, zeptomailToken, loopsApiKey, mailtrapApiToken.

OAuth providers

object

Gmail needs gmailRefreshToken + gmailClientId + gmailClientSecret; Microsoft 365 needs microsoftRefreshToken + microsoftClientId + microsoftClientSecret + microsoftTenantId.

The From address

There is no sendingAddress field. The From address is resolved as:

  1. config.from, if set.

  2. config.address — the inbound address, so replies thread back onto the same channel.

  3. config.username, if it parses as an email address.

If none of the three is usable the send fails rather than emitting an unroutable sender. A half-configured Mailgun or Mailjet credential fails with <provider>_incomplete_config before the provider is called.

Inbound

Point your provider's inbound parse webhook at the matching receiver. Each verifies the provider's own credential, then resolves the channel by the recipient address.

Inbound receiver endpoints

Name

Type

Description

Postmark

POST

/v1/email/inbound/postmark — HTTP Basic, checked against config.postmarkUser + config.postmarkPass

SendGrid

POST

/v1/email/inbound/sendgrid — HTTP Basic, checked against config.sendgridUser + config.sendgridPass

Mailgun

POST

/v1/email/inbound/mailgun — Mailgun HMAC over timestamp + token, checked against config.mailgunApiKey; 10-minute freshness window

AWS SES → SNS

POST

/v1/email/inbound/ses — SNS signature verified against Amazon's certificate; config.sesSharedSecret is an optional second factor sent as x-chatly-shared-secret

POST /v1/email/inbound/postmark — Public

POST /v1/email/inbound/sendgrid — Public

POST /v1/email/inbound/mailgun — Public

POST /v1/email/inbound/ses — Public

Warning — No credentials on the channel means no inbound

A receiver whose credential keys are absent answers 401 and refuses the mail rather than accepting it unverified. Set the Postmark, SendGrid and Mailgun keys above under Inbound relay credentials on the email channel's setup form (or with PATCH /v1/channels/{id}) before you repoint MX — otherwise the first messages your provider forwards are rejected and, depending on the provider, retried for a while and then dropped.

Wiring inbound

Postmark

Postmark → Servers → Inbound stream → Settings:

  • Webhook URLhttps://api.chatlychat.com/v1/email/inbound/postmark

  • Webhook authentication → Basic Auth → the user/pass you stored as postmarkUser / postmarkPass under Inbound relay credentials on the channel's setup form.

  • Inbound domain → the domain whose mail you want filed here.

Update your MX to point at inbound.postmarkapp.com.

SendGrid

SendGrid → Settings → Inbound Parse → Add host & URL:

  • Hostinbound.acme.com

  • Destination URLhttps://api.chatlychat.com/v1/email/inbound/sendgrid

  • Basic Auth → embed the sendgridUser / sendgridPass you stored under Inbound relay credentials on the channel's setup form in the URL.

Update MX to mx.sendgrid.net.

Mailgun

Mailgun → Sending → Domains → Receiving → Routes → New route:

  • Match recipient: match_recipient(".*@inbound.acme.com")

  • Forward: forward("https://api.chatlychat.com/v1/email/inbound/mailgun")

  • Stop: enabled (so the message isn't double-handled)

We verify Mailgun's HMAC against config.mailgunApiKey — the Mailgun API key field under Inbound relay credentials on the channel's setup form.

AWS SES

  1. SES → Email receiving → Rule sets → Create rule.

  2. Add an SNS topic action; create the topic if needed.

  3. SNS → Topics → Subscribe:

    • Protocol: HTTPS

    • Endpoint: https://api.chatlychat.com/v1/email/inbound/ses

    • SNS POSTs a SubscriptionConfirmation; we verify its signature, confirm that SubscribeURL really is an Amazon host, and auto-confirm.

  4. Set MX records to point at SES's inbound mail server.

Info — The recipient address must match the channel exactly

Resolution is lower(config->>'address') = lower(recipient). No wildcards, no plus-address stripping, no domain matching. Mail for an address no channel claims is answered 404 no channel for that address.

DNS for sending + inbound

A typical setup:

;; Sending (SPF + DKIM + DMARC)
acme.com.            TXT   "v=spf1 include:spf.mtasv.net ~all"
pm._domainkey.acme.com. TXT "k=rsa; p=MIGfMA0GCSqGSIb3..."
_dmarc.acme.com.     TXT   "v=DMARC1; p=quarantine; rua=mailto:[email protected]"

;; Inbound (MX → provider)
inbound.acme.com.    MX 10 inbound.postmarkapp.com.

Use Domain Connect for one-click DNS at supported registrars. Otherwise add records manually.

Tip — DMARC is not optional

Gmail and Yahoo require DMARC alignment for any sender doing more than 5,000 messages/day to their users. Start with p=none, inspect reports, then move to p=quarantine or p=reject.

Threading

Inbound mail threads on the RFC 5322 header chain only:

  1. The root of the References chain — the first non-empty entry. It is stable for the life of a thread, so a reply lands on the right conversation even after that conversation was closed.

  2. Otherwise In-Reply-To.

  3. Otherwise the message's own Message-ID, which starts a new thread.

That value becomes the conversation's external_conversation_id. There is no plus-addressing scheme and no subject-similarity fallback: a client that strips References and In-Reply-To starts a new conversation.

Replies to a ticket side conversation are the one exception — our outbound side-conversation mail carries an HMAC-signed Message-ID, and a reply echoing it is filed on the side conversation instead of opening a thread for the vendor.

Attachments

Inbound attachments are carried through by URL, as the provider hosted them. Entries without a url are dropped, because the agent UI would render them as a broken link.

Warning — No download, no scanning, no size cap

There is no MIME-sniffing, no executable-extension blocklist, no virus scanning and no re-hosting step on the inbound email path. Files are surfaced to agents as links to the provider's storage. Treat inbound attachments the way you would treat any attachment on an unfiltered mailbox.

Delivery events — bounces, complaints, opens, clicks

Provider event webhooks POST to a different route from inbound parse:

POST /v1/webhooks/email/{provider} — Public

{provider} is one of postmark, sendgrid, mailgun, ses, brevo. Anything else answers 404. Events normalise to delivered, opened, clicked, bounced, spam, unsubscribed, dropped, deferred, deduplicated on <provider>:<providerMessageId>:<eventType>:<unix>, and are matched back to a message through messages.metadata.externalMessageId.

Warning — These verify against deployment-wide env vars, not per-channel keys

Unlike inbound parse, the tracking receiver checks a platform secret: POSTMARK_WEBHOOK_BASIC, SENDGRID_WEBHOOK_BASIC, MAILGUN_API_KEY, SES_TRACKING_SHARED_SECRET or BREVO_WEBHOOK_SECRET. If the relevant one is unset, the endpoint rejects every event in production. Self-hosters must set these before pointing a provider at the route.

Per-message events are readable back through GET /v1/messages/{id}/email-events.

Consent and suppression

Email is opt-out: an address with no recorded opt-out may be mailed. contacts.unsubscribedAt is the global "do not contact" flag, and per-channel consent lives on contact_channel_consent with a state of granted or revoked. Campaign sending is the surface that reads it; agent replies inside an open conversation are not gated on it.

Self-hosted notes

Even on a fully self-hosted install, you still bring your own mail path. We do not run Haraka, Postfix, or any SMTP server. smtp-sender is an SMTP client — it connects out to your relay.

If your requirement is "all email egress goes through our own infrastructure," point smtpHost at your own relay and leave every hosted-provider key unset.

Troubleshooting

Warning — Outbound succeeds but replies never appear

In order of likelihood: no inbound receiver is wired at all (the mailbox provider selector does not fetch mail); the channel's address does not exactly match the envelope recipient; or the provider credential keys are missing, so the receiver is answering 401.

Warning — Every outbound email fails on a fully filled-in channel

Check that smtpHost is set — without it the sender falls through to the HTTP-provider path and, with no provider key configured, gives up. Also check the From resolution: no from, no address and a non-email username is a hard failure by design.

Warning — Threading breaks after one reply

An email client or auto-responder stripped References and In-Reply-To. There is no fallback signal, so the reply opens a new conversation.

Was this page helpful?