Skip to content

Inbound email

Receive mail sent to your own domain: PostStack stores every message, parses it, posts it to your webhook and — when the address is a mailbox — delivers it to that inbox.

How inbound mail flows#

  1. The sender's mail server looks up your domain's MX record, finds inbound.poststack.dev, and connects on port 25.
  2. Postfix accepts the message only for domains with inbound enabled, and runs it through rspamd, which scores it and enforces the sender domain's DMARC policy (see Spam filtering).
  3. The message is handed to PostStack's inbound server, parsed, and stored once per recipient address.
  4. For each stored copy PostStack fires the email.inbound webhook, and — if the recipient is an active mailbox or an alias of one — delivers the original message to that mailbox, where IMAP, POP3 and webmail see it.

You do not need a mailbox to receive mail. Every address on an inbound-enabled domain is accepted and stored, so a webhook-only setup (support tickets, reply parsing, email-to-app) works without creating anything else.

Enable inbound on a domain#

  1. Turn inbound on

    In the dashboard, open the domain and switch on Inbound email, or set inbound_enabled through the API. The domain does not have to be verified first, but you can only reply from a verified domain.

    // The SDK takes the numeric domain id; the REST API also accepts dom_…
    await poststack.domains.update(12, { inbound_enabled: true });
  2. Publish the MX record

    Enabling inbound adds an MX record to the domain's DNS checklist. Publish it at your DNS host:

    TypeNameValuePriority
    MXyourdomain.cominbound.poststack.dev10

    The number itself is not what matters: mail servers always try the lowest priority first, so no other MX record on the domain may have a lower number than ours. Remove any old MX records (and any null MX, MX 0 .), or mail keeps going to them. The domain page re-checks the record and names what is wrong if it is outranked.

  3. Subscribe to the webhook

    Create a webhook for email.inbound (see below), or read messages with GET /inbound.

Turning inbound on routes the whole domain's mail to PostStack

Once the MX record points at us, mail for every address on the domain comes here. If people already receive mail on this domain elsewhere (Google Workspace, Microsoft 365), use a subdomain such as reply.yourdomain.com for inbound instead.

What is accepted#

LimitValueWhat happens past it
Message size25 MBThe message is refused and the sender gets a bounce.
Recipients per message50Recipients past the 50th are deferred with 452; the sender's server retries them.
Recipient domainInbound enabledMail for any other domain is refused during the SMTP conversation.
Duplicate deliverySame Message-ID to the same addressStored once. A retried delivery does not create a second row or a second webhook.

Attachments are stored with the message and have no separate cap beyond the message size. A message with no Message-ID is never treated as a duplicate.

Spam filtering#

Every inbound message passes through rspamd before it is accepted. Two outcomes stop a message during the SMTP conversation, so it is never stored and no webhook fires:

  • a score at or above rspamd's reject threshold;
  • a sender whose domain publishes p=reject in DMARC and whose message fails DMARC.

Everything else is stored and delivered, with the score kept in the X-Spamd-Result header. The API and the webhook read it for you as spamScore / spam_score and spamVerdict / spam_verdict, so filter on those in your handler rather than trusting every message. In a mailbox, messages flagged as spam (including DMARC p=quarantine failures) are filed into the Junk folder.

Mailboxes, aliases and forwarding#

Inbound storage and mailbox delivery are independent. When the recipient address is:

Recipient isStored + webhookDelivered to
An active mailboxYes (no webhook if the mailbox has webhookEnabled: false)That mailbox (IMAP, POP3, webmail)
An aliasYesThe alias’s destination mailbox, if it is active
NeitherYesNowhere else — the API and the webhook are the only copies

A mailbox with forwarding turned on forwards the message first; when it is set not to keep a copy, the mailbox copy is skipped only if the forward actually went out. See Mailboxes for creating mailboxes, aliases and catch-all addresses.

The email.inbound webhook#

Subscribe a webhook to email.inbound to be told the moment a message is stored. It fires once per stored copy (so once per recipient address), and never for a duplicate delivery — except for mail to a mailbox whose webhookEnabled is false: that message is stored, but email.inbound does not fire. The payload is a summary: fetch the bodies and attachments with GET /inbound/:id.

await poststack.webhooks.create({
  url: 'https://yourapp.com/webhooks/poststack',
  events: ['email.inbound'],
});
Payload
{
  "type": "email.inbound",
  "created_at": "2026-03-23T10:00:01.530Z",
  "data": {
    "id": 7,
    "from": "jane@external.com",
    "from_name": "Jane Sender",
    "to": "support+invoice-42@yourdomain.com",
    "cc": ["colleague@external.com"],
    "subject": "Re: Invoice 42",
    "domain": "yourdomain.com",
    "mailbox_hash": "invoice-42",
    "spam_score": 1.8,
    "spam_verdict": "clean",
    "stripped_reply": "Thanks, that worked!",
    "received_at": "2026-03-23T10:00:01.529Z"
  }
}
data.idinteger
The inbound email id — pass it to GET /inbound/:id, reply and forward.
data.fromstring
Bare sender address.
data.from_namestring | null
Sender display name.
data.tostring
The recipient address this copy was delivered to.
data.ccstring[]
Addresses on the Cc header; empty when there were none.
data.subjectstring | null
Subject line.
data.domainstring
Your domain the message arrived on.
data.mailbox_hashstring | null
Plus-address tag of to. Put a ticket or user id in the address you send from (reply+ticket-981@yourdomain.com) and route the reply by this field.
data.spam_scorenumber | null
rspamd score; null when unavailable.
data.spam_verdict"spam" | "clean" | null
rspamd's spam flag.
data.stripped_replystring | null
The new text of a reply with the quoted history removed.
data.received_atstring (ISO 8601)
When PostStack processed the message.

Deliveries are signed and retried like every other event — see Webhooks for verifying the signature. To get a human-readable alert in Slack, Discord, Telegram or email instead, use notification channels, which receive the same message as inbound_email.received.

The inbound_email.received payload has the same fields as above except stripped_reply, plus mailbox_id, message_id and text_preview (the first 500 characters of the text body). The preview is also sent under the old camelCase key textPreview, which is deprecated and will be removed — read text_preview.

Endpoints#

Base URL https://api.poststack.dev. Reading inbound mail needs a full access API key (scope mailboxes:manage); a sending-only key gets 403 insufficient_scope. Reply and forward need email:send, which both key types have.

Reply and forward send exactly as POST /emails does with the same key: a sk_test_ key sends in test mode (accepted and recorded, nothing delivered), and a key restricted to one domain can only reply or forward from that domain.

List inbound emails#

Stored inbound messages for your team, newest first.

GEThttps://api.poststack.dev/inbound
Request
const { data, meta } = await poststack.inboundEmails.list({
  page: 1,
  per_page: 20,
  domain: 'yourdomain.com',
});
{
  "data": [
    {
      "id": 7,
      "teamId": 3,
      "domainId": 12,
      "fromAddress": "jane@external.com",
      "fromName": "Jane Sender",
      "toAddress": "support+invoice-42@yourdomain.com",
      "toAddresses": [{ "address": "support+invoice-42@yourdomain.com", "name": null }],
      "ccAddresses": [{ "address": "colleague@external.com", "name": "A Colleague" }],
      "messageId": "<abc123@external.com>",
      "subject": "Re: Invoice 42",
      "htmlBody": "<p>Thanks, that worked!</p>",
      "textBody": "Thanks, that worked!\n\nOn Mon, 23 Mar 2026 at 09:12, Support wrote:\n> ...",
      "headers": {
        "message-id": "<abc123@external.com>",
        "date": "Mon, 23 Mar 2026 10:00:00 +0000",
        "x-spamd-result": "default: False [1.80 / 15.00]; ..."
      },
      "createdAt": "2026-03-23T10:00:01.412Z",
      "mailboxHash": "invoice-42",
      "spamScore": 1.8,
      "spamVerdict": "clean",
      "strippedReply": "Thanks, that worked!"
    }
  ],
  "meta": { "page": 1, "perPage": 20, "total": 48, "totalPages": 3 }
}

Query parameters

pageintegerdefault 1
Page number, from 1.
per_pageintegerdefault 20
Results per page, 1–100.
domainstring
Only messages received on this domain name, e.g. yourdomain.com. A domain your team does not own returns an empty page, not an error.
tostring
Only messages delivered to this address, e.g. support@yourdomain.com. Exact match, case-insensitive.
qstring
Search the subject, sender address and sender name.
reports"include" | "exclude" | "only"default include
DMARC and TLS-RPT aggregate reports (subject Report Domain: …). Pass exclude to list only mail people sent.

Response fields

data[].idinteger
Numeric id of the stored message. Inbound ids are plain integers, not prefixed strings.
data[].teamIdinteger
Your team id.
data[].domainIdinteger | null
Numeric id of the inbound domain the message arrived on.
data[].fromAddressstring
Bare sender address from the From header, falling back to the envelope sender when the header has none.
data[].fromNamestring | null
Display name from the From header, or null.
data[].toAddressstring
The envelope recipient this copy was delivered to. A message sent to two of your addresses is stored twice, once per recipient.
data[].toAddresses{ address, name }[] | null
Everyone on the To header, not only the delivered-to address.
data[].ccAddresses{ address, name }[] | null
Everyone on the Cc header.
data[].messageIdstring | null
The message's Message-ID, used to drop duplicate deliveries.
data[].subjectstring | null
Subject line.
data[].htmlBodystring | null
HTML part, as received (not sanitised — sanitise before rendering it in a browser).
data[].textBodystring | null
Plain-text part.
data[].headersobject | null
Every single-valued header, keys lower-cased. Address headers (From, To, Cc) are in the fields above instead.
data[].createdAtstring (ISO 8601)
When PostStack stored the message.
data[].mailboxHashstring | null
The plus-address tag of toAddress: support+invoice-42@ gives invoice-42. null when the address has no tag.
data[].spamScorenumber | null
The score our spam filter (rspamd) gave the message, read from its X-Spamd-Result header. null when the message carries no such header.
data[].spamVerdict"spam" | "clean" | null
rspamd's own spam flag from the same header.
data[].strippedReplystring | null
The text body with the quoted history cut off at the first On … wrote:, -----Original Message-----, Outlook From: … Sent: (or Danish Fra: … Sendt:) block, or the underscore rule Outlook draws above it. The whole text when there is no quote; null when there is no text body.
data[].replyCountinteger
How many emails you have sent in reply to the message.
meta.pageinteger
Current page.
meta.perPageinteger
Page size.
meta.totalinteger
Messages matching the filter.
meta.totalPagesinteger
Number of pages.

Status codes

StatusMeaning
200The page of messages.
400A query parameter failed validation (e.g. per_page above 100).
401Missing, invalid or revoked API key.
403The key lacks the required scope (insufficient_scope), or the team is suspended.

Get an inbound email#

One message with its full bodies and headers, wrapped in an inboundEmail key (the SDK unwraps it), and the emails sent in reply to it.

GEThttps://api.poststack.dev/inbound/:id
Request
const email = await poststack.inboundEmails.get(7);
console.log(email.fromAddress, email.strippedReply);
{
  "inboundEmail": {
    "id": 7,
    "teamId": 3,
    "domainId": 12,
    "fromAddress": "jane@external.com",
    "fromName": "Jane Sender",
    "toAddress": "support+invoice-42@yourdomain.com",
    "toAddresses": [{ "address": "support+invoice-42@yourdomain.com", "name": null }],
    "ccAddresses": [{ "address": "colleague@external.com", "name": "A Colleague" }],
    "messageId": "<abc123@external.com>",
    "subject": "Re: Invoice 42",
    "htmlBody": "<p>Thanks, that worked!</p>",
    "textBody": "Thanks, that worked!\n\nOn Mon, 23 Mar 2026 at 09:12, Support wrote:\n> ...",
    "headers": {
      "message-id": "<abc123@external.com>",
      "date": "Mon, 23 Mar 2026 10:00:00 +0000",
      "x-spamd-result": "default: False [1.80 / 15.00]; ..."
    },
    "createdAt": "2026-03-23T10:00:01.412Z",
    "mailboxHash": "invoice-42",
    "spamScore": 1.8,
    "spamVerdict": "clean",
    "strippedReply": "Thanks, that worked!"
  },
  "replies": []
}

Path parameters

idintegerrequired
The inbound email id.

Response fields

inboundEmail.idinteger
Numeric id of the stored message. Inbound ids are plain integers, not prefixed strings.
inboundEmail.teamIdinteger
Your team id.
inboundEmail.domainIdinteger | null
Numeric id of the inbound domain the message arrived on.
inboundEmail.fromAddressstring
Bare sender address from the From header, falling back to the envelope sender when the header has none.
inboundEmail.fromNamestring | null
Display name from the From header, or null.
inboundEmail.toAddressstring
The envelope recipient this copy was delivered to. A message sent to two of your addresses is stored twice, once per recipient.
inboundEmail.toAddresses{ address, name }[] | null
Everyone on the To header, not only the delivered-to address.
inboundEmail.ccAddresses{ address, name }[] | null
Everyone on the Cc header.
inboundEmail.messageIdstring | null
The message's Message-ID, used to drop duplicate deliveries.
inboundEmail.subjectstring | null
Subject line.
inboundEmail.htmlBodystring | null
HTML part, as received (not sanitised — sanitise before rendering it in a browser).
inboundEmail.textBodystring | null
Plain-text part.
inboundEmail.headersobject | null
Every single-valued header, keys lower-cased. Address headers (From, To, Cc) are in the fields above instead.
inboundEmail.createdAtstring (ISO 8601)
When PostStack stored the message.
inboundEmail.mailboxHashstring | null
The plus-address tag of toAddress: support+invoice-42@ gives invoice-42. null when the address has no tag.
inboundEmail.spamScorenumber | null
The score our spam filter (rspamd) gave the message, read from its X-Spamd-Result header. null when the message carries no such header.
inboundEmail.spamVerdict"spam" | "clean" | null
rspamd's own spam flag from the same header.
inboundEmail.strippedReplystring | null
The text body with the quoted history cut off at the first On … wrote:, -----Original Message-----, Outlook From: … Sent: (or Danish Fra: … Sendt:) block, or the underscore rule Outlook draws above it. The whole text when there is no quote; null when there is no text body.
replies[]object[]
Emails sent in reply to it with the reply endpoint, oldest first: id (em_…), subject, toAddresses, status, testMode, createdAt.

Status codes

StatusMeaning
200The message.
400id is not a positive integer.
401Missing, invalid or revoked API key.
403The key lacks the required scope (insufficient_scope), or the team is suspended.
404Inbound email not found — no such id in your team.

List attachments#

Metadata for every attachment on a message. Inline images count as attachments.

GEThttps://api.poststack.dev/inbound/:id/attachments
Request
const attachments = await poststack.inboundEmails.listAttachments(7);
{
  "attachments": [
    {
      "id": 31,
      "filename": "screenshot.png",
      "contentType": "image/png",
      "size": 24576,
      "createdAt": "2026-03-23T10:00:01.412Z"
    }
  ]
}

Path parameters

idintegerrequired
The inbound email id.

Response fields

attachments[].idinteger
Attachment id, for the download endpoint.
attachments[].filenamestring
File name from the MIME part; attachment-… when the part had none.
attachments[].contentTypestring
MIME type as the sender declared it.
attachments[].sizeinteger
Size in bytes.
attachments[].createdAtstring (ISO 8601)
When it was stored.

Status codes

StatusMeaning
200The list; empty attachments when there are none.
400id is not a positive integer.
401Missing, invalid or revoked API key.
403The key lacks the required scope (insufficient_scope), or the team is suspended.
404Inbound email not found.

Download an attachment#

The raw bytes of one attachment, served with its Content-Type, Content-Length and a Content-Disposition: attachment filename. The SDK returns a Uint8Array.

GEThttps://api.poststack.dev/inbound/:id/attachments/:aid
Request
const bytes = await poststack.inboundEmails.downloadAttachment(7, 31);
await Bun.write('screenshot.png', bytes);

Path parameters

idintegerrequired
The inbound email id.
aidintegerrequired
The attachment id.

Status codes

StatusMeaning
200The file bytes.
400id or aid is not a positive integer.
401Missing, invalid or revoked API key.
403The key lacks the required scope (insufficient_scope), or the team is suspended.
404Attachment not found — the message or the attachment does not exist in your team.
410attachment_file_missing — the record exists but the stored file is gone. Retrying will not help.

Reply to an inbound email#

Send a reply to the original sender. PostStack sets the subject (Re: added unless already there) and In-Reply-To / References so the reply threads, quotes the original under your text, then sends it like POST /emails.

POSThttps://api.poststack.dev/inbound/:id/reply
Request
const { id } = await poststack.inboundEmails.reply(7, {
  from: 'Support <support@yourdomain.com>',
  html: '<p>Glad it works now.</p>',
});
{
  "id": "em_k3j9x2m8q1w7r4t6"
}

Path parameters

idintegerrequired
The inbound email id.

Body parameters

fromstringrequired
Sender, you@yourdomain.com or Name <you@yourdomain.com>. Must be on a verified domain of your team. If it is one of your mailboxes, its signature is added between your text and the quote, and a copy is saved to the mailbox's Sent folder.
htmlstring
HTML body. Provide html, text, or both.
textstring
Plain-text body.
ccstring[]
Extra Cc addresses. Counts toward the 50-recipient limit.
bccstring[]
Extra Bcc addresses. Counts toward the 50-recipient limit.
quote_originalbooleandefault true
Add On <date>, <sender> wrote: and the original under the reply, quoted as text (> lines and a <blockquote>). Only the sender's new text is quoted — their own quoted history is cut, as in strippedReply. A body that already has a blockquote or > lines (e.g. a draft-reply suggestion) is not quoted again. Pass false to send your body as written.

Response fields

idstring
Id of the outbound email (em_…); track it on the Emails API.
test_modetrue
Present only when sent with an sk_test_ key: nothing was delivered.

Status codes

StatusMeaning
202Accepted and queued for sending.
400Body failed validation (missing from, malformed cc/bcc address).
401Missing, invalid or revoked API key.
403Missing scope, team suspended, sending paused for the team, or the API key is restricted to a different domain than from.
404Inbound email not found.
422The from domain is not yours or not verified, every recipient is suppressed, there are more than 50 recipients across to, cc and bcc, or the content was rejected by the outbound spam check.
429rate_limit_exceeded: more than 60 requests a minute on this endpoint (wait Retry-After). A sending quota has its own code and will not clear on retry: monthly_limit_exceeded, daily_limit_exceeded or send_limit_exceeded.

The reply goes to the original fromAddress. A Reply-To header on the inbound message is not followed — if you need it, read it from headers and send with POST /emails.

Write only your new text. The body that goes out is your text, then the mailbox signature (or wherever you put {{signature}}), then the quote. The reply is linked to the inbound email: it is listed under replies on GET /inbound/:id, and the dashboard shows the conversation on both messages.

Forward an inbound email#

Send the message, with its attachments (any whose stored file is missing is left out), to new recipients (with optional Cc and Bcc). The subject gets Fwd: and the body starts with your note and a forwarded-message header block. No mailbox signature is added.

POSThttps://api.poststack.dev/inbound/:id/forward
Request
const { id } = await poststack.inboundEmails.forward(7, {
  from: 'Support <support@yourdomain.com>',
  to: ['escalation@yourdomain.com'],
  cc: ['account-manager@yourdomain.com'],
  message: 'Customer on the Pro plan, please take this one.',
});
{
  "id": "em_p8v2c6n0z4y1h5g3"
}

Path parameters

idintegerrequired
The inbound email id.

Body parameters

fromstringrequired
Sender on one of your verified domains.
tostring[]required
At least one recipient; 50 at most across to, cc and bcc.
ccstring[]
Extra Cc addresses. Counts toward the 50-recipient limit. Suppressed addresses are dropped, as for to.
bccstring[]
Extra Bcc addresses. Counts toward the 50-recipient limit. Suppressed addresses are dropped, as for to.
messagestring
Optional note placed above the forwarded message.

Response fields

idstring
Id of the outbound email (em_…).
test_modetrue
Present only when sent with an sk_test_ key: nothing was delivered.

Status codes

StatusMeaning
202Accepted and queued for sending.
400Body failed validation (missing from, empty or malformed to, malformed cc/bcc address).
401Missing, invalid or revoked API key.
403Missing scope, team suspended, sending paused for the team, or the API key is restricted to a different domain than from.
404Inbound email not found.
422The from domain is not yours or not verified, every recipient is suppressed, there are more than 50 recipients across to, cc and bcc, or the attachments exceed the outbound limits (10 MB per file, 25 MB in total).
429rate_limit_exceeded: more than 60 requests a minute on this endpoint (wait Retry-After). A sending quota has its own code and will not clear on retry: monthly_limit_exceeded, daily_limit_exceeded or send_limit_exceeded.

Build a reply draft#

Returns a reply skeleton without sending anything: suggested sender and subject, threading headers, a salutation and sign-off in the chosen tone, and the original quoted in HTML and text. Fill in the body and send it with the reply endpoint. This is what the MCP draft_from_thread tool uses.

POSThttps://api.poststack.dev/inbound/:id/draft-reply
Request
const draft = await poststack.inboundEmails.draftReply(7, { tone: 'formal' });
const html = draft.suggested_html.replace(draft.body_placeholder, 'Your refund is on its way.');
await poststack.inboundEmails.reply(7, { from: draft.suggested_from, html });

Path parameters

idintegerrequired
The inbound email id.

Body parameters

tone"formal" | "friendly" | "casual"default friendly
Picks the salutation and sign-off: Dear/Kind regards,, Hi/Thanks, or Hey/Cheers,.

Response fields

inboundobject
The message being answered: id, from, to, subject, received_at.
suggested_fromstring
The address the message was sent to.
suggested_subjectstring
Subject with Re:.
salutationstring
e.g. Hi Jane,.
body_placeholderstring
The placeholder text in the suggested bodies, for you to replace.
quoted_htmlstring
The original, escaped and quoted as a blockquote.
quoted_textstring
The original with > line prefixes.
threadingobject
in_reply_to and references for the reply.
suggested_htmlstring
Salutation, placeholder, sign-off and the quoted original, as HTML.
suggested_textstring
The same as plain text.
signs_automaticallyboolean
True when suggested_from is a mailbox with a signature; the suggested bodies then contain a {{signature}} tag where it goes.

Status codes

StatusMeaning
200The draft.
400Invalid id or tone.
401Missing, invalid or revoked API key.
403The key lacks the required scope (insufficient_scope), or the team is suspended.
404Inbound email not found.
429More than 60 requests a minute on this endpoint.

Other ways to read inbound mail#

ToolHow
DashboardThe Inbound page lists every stored message.
CLIpoststack inbound list|get|attachments|download|reply|forward
Python SDKclient.inbound_emails.list(), get(), list_attachments(), download_attachment(), draft_reply(), reply(), forward()
Go SDKclient.InboundEmails.List, Get, ListAttachments, DownloadAttachment, DraftReply, Reply, Forward
MCPlist_inbound_emails, get_inbound_email, list_inbound_email_attachments, draft_from_thread, reply_to_inbound_email, forward_inbound_email

Troubleshooting#

Mail sent to my domain never shows up

Check, in order: inbound is enabled on the domain; the MX record is published and points at inbound.poststack.dev; no other MX record has a lower priority number (including a null MX, MX 0 .). The domain page's DNS check names the exact record at fault. Then ask the sender for their bounce: a DMARC p=reject failure or a spam rejection is refused at our door and never stored.

The same message arrives twice in my webhook handler

It was sent to two of your addresses (for example To one, Cc the other): each recipient is its own stored copy with its own id. Compare to. A retried delivery of the same message to the same address is not stored twice.

spam_score and spam_verdict are null

The message has no X-Spamd-Result header, which means it did not come in through the public MX path. There is no score to report, so none is invented.

403 insufficient_scope on GET /inbound

The API key is a sending-only key. Reading inbound mail needs a full-access key.

Next steps

Was this page helpful?

Related