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#
- The sender's mail server looks up your domain's MX record, finds
inbound.poststack.dev, and connects on port 25. - 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).
- The message is handed to PostStack's inbound server, parsed, and stored once per recipient address.
- For each stored copy PostStack fires the
email.inboundwebhook, 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#
Turn inbound on
In the dashboard, open the domain and switch on Inbound email, or set
inbound_enabledthrough 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 });curl -X PATCH https://api.poststack.dev/domains/dom_abc123 \ -H "Authorization: Bearer $POSTSTACK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "inbound_enabled": true }'Publish the MX record
Enabling inbound adds an MX record to the domain's DNS checklist. Publish it at your DNS host:
Type Name Value Priority MX yourdomain.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.Subscribe to the webhook
Create a webhook for
email.inbound(see below), or read messages withGET /inbound.
What is accepted#
| Limit | Value | What happens past it |
|---|---|---|
| Message size | 25 MB | The message is refused and the sender gets a bounce. |
| Recipients per message | 50 | Recipients past the 50th are deferred with 452; the sender's server retries them. |
| Recipient domain | Inbound enabled | Mail for any other domain is refused during the SMTP conversation. |
| Duplicate delivery | Same Message-ID to the same address | Stored 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=rejectin 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 is | Stored + webhook | Delivered to |
|---|---|---|
| An active mailbox | Yes (no webhook if the mailbox has webhookEnabled: false) | That mailbox (IMAP, POP3, webmail) |
| An alias | Yes | The alias’s destination mailbox, if it is active |
| Neither | Yes | Nowhere 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'],
});curl -X POST https://api.poststack.dev/webhooks \
-H "Authorization: Bearer $POSTSTACK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://yourapp.com/webhooks/poststack", "events": ["email.inbound"] }'{
"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
Ccheader; 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;
nullwhen 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.
https://api.poststack.dev/inboundconst { data, meta } = await poststack.inboundEmails.list({
page: 1,
per_page: 20,
domain: 'yourdomain.com',
});curl "https://api.poststack.dev/inbound?per_page=20&domain=yourdomain.com" \
-H "Authorization: Bearer $POSTSTACK_API_KEY"{
"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
pageintegerdefault1- Page number, from 1.
per_pageintegerdefault20- 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"defaultinclude- DMARC and TLS-RPT aggregate reports (subject
Report Domain: …). Passexcludeto 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
Fromheader, falling back to the envelope sender when the header has none. data[].fromNamestring | null- Display name from the
Fromheader, ornull. 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
Toheader, not only the delivered-to address. data[].ccAddresses{ address, name }[] | null- Everyone on the
Ccheader. 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@givesinvoice-42.nullwhen the address has no tag. data[].spamScorenumber | null- The score our spam filter (rspamd) gave the message, read from its
X-Spamd-Resultheader.nullwhen 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-----, OutlookFrom: … Sent:(or DanishFra: … Sendt:) block, or the underscore rule Outlook draws above it. The whole text when there is no quote;nullwhen 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
| Status | Meaning |
|---|---|
| 200 | The page of messages. |
| 400 | A query parameter failed validation (e.g. per_page above 100). |
| 401 | Missing, invalid or revoked API key. |
| 403 | The 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.
https://api.poststack.dev/inbound/:idconst email = await poststack.inboundEmails.get(7);
console.log(email.fromAddress, email.strippedReply);curl https://api.poststack.dev/inbound/7 \
-H "Authorization: Bearer $POSTSTACK_API_KEY"{
"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
Fromheader, falling back to the envelope sender when the header has none. inboundEmail.fromNamestring | null- Display name from the
Fromheader, ornull. 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
Toheader, not only the delivered-to address. inboundEmail.ccAddresses{ address, name }[] | null- Everyone on the
Ccheader. 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@givesinvoice-42.nullwhen the address has no tag. inboundEmail.spamScorenumber | null- The score our spam filter (rspamd) gave the message, read from its
X-Spamd-Resultheader.nullwhen 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-----, OutlookFrom: … Sent:(or DanishFra: … Sendt:) block, or the underscore rule Outlook draws above it. The whole text when there is no quote;nullwhen 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
| Status | Meaning |
|---|---|
| 200 | The message. |
| 400 | id is not a positive integer. |
| 401 | Missing, invalid or revoked API key. |
| 403 | The key lacks the required scope (insufficient_scope), or the team is suspended. |
| 404 | Inbound email not found — no such id in your team. |
List attachments#
Metadata for every attachment on a message. Inline images count as attachments.
https://api.poststack.dev/inbound/:id/attachmentsconst attachments = await poststack.inboundEmails.listAttachments(7);curl https://api.poststack.dev/inbound/7/attachments \
-H "Authorization: Bearer $POSTSTACK_API_KEY"{
"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
| Status | Meaning |
|---|---|
| 200 | The list; empty attachments when there are none. |
| 400 | id is not a positive integer. |
| 401 | Missing, invalid or revoked API key. |
| 403 | The key lacks the required scope (insufficient_scope), or the team is suspended. |
| 404 | Inbound 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.
https://api.poststack.dev/inbound/:id/attachments/:aidconst bytes = await poststack.inboundEmails.downloadAttachment(7, 31);
await Bun.write('screenshot.png', bytes);curl https://api.poststack.dev/inbound/7/attachments/31 \
-H "Authorization: Bearer $POSTSTACK_API_KEY" \
--output screenshot.pngPath parameters
idintegerrequired- The inbound email id.
aidintegerrequired- The attachment id.
Status codes
| Status | Meaning |
|---|---|
| 200 | The file bytes. |
| 400 | id or aid is not a positive integer. |
| 401 | Missing, invalid or revoked API key. |
| 403 | The key lacks the required scope (insufficient_scope), or the team is suspended. |
| 404 | Attachment not found — the message or the attachment does not exist in your team. |
| 410 | attachment_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.
https://api.poststack.dev/inbound/:id/replyconst { id } = await poststack.inboundEmails.reply(7, {
from: 'Support <support@yourdomain.com>',
html: '<p>Glad it works now.</p>',
});curl -X POST https://api.poststack.dev/inbound/7/reply \
-H "Authorization: Bearer $POSTSTACK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "from": "Support <support@yourdomain.com>", "html": "<p>Glad it works now.</p>" }'{
"from": "Support <support@yourdomain.com>",
"html": "<p>Glad it works now.</p>",
"text": "Glad it works now.",
"cc": ["team@yourdomain.com"]
}{
"id": "em_k3j9x2m8q1w7r4t6"
}Path parameters
idintegerrequired- The inbound email id.
Body parameters
fromstringrequired- Sender,
you@yourdomain.comorName <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_originalbooleandefaulttrue- 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 instrippedReply. A body that already has a blockquote or>lines (e.g. a draft-reply suggestion) is not quoted again. Passfalseto 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
| Status | Meaning |
|---|---|
| 202 | Accepted and queued for sending. |
| 400 | Body failed validation (missing from, malformed cc/bcc address). |
| 401 | Missing, invalid or revoked API key. |
| 403 | Missing scope, team suspended, sending paused for the team, or the API key is restricted to a different domain than from. |
| 404 | Inbound email not found. |
| 422 | The 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. |
| 429 | rate_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. |
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.
https://api.poststack.dev/inbound/:id/forwardconst { 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.',
});curl -X POST https://api.poststack.dev/inbound/7/forward \
-H "Authorization: Bearer $POSTSTACK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "from": "support@yourdomain.com", "to": ["escalation@yourdomain.com"] }'{
"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
| Status | Meaning |
|---|---|
| 202 | Accepted and queued for sending. |
| 400 | Body failed validation (missing from, empty or malformed to, malformed cc/bcc address). |
| 401 | Missing, invalid or revoked API key. |
| 403 | Missing scope, team suspended, sending paused for the team, or the API key is restricted to a different domain than from. |
| 404 | Inbound email not found. |
| 422 | The 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). |
| 429 | rate_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.
https://api.poststack.dev/inbound/:id/draft-replyconst 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 });curl -X POST https://api.poststack.dev/inbound/7/draft-reply \
-H "Authorization: Bearer $POSTSTACK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "tone": "formal" }'{
"tone": "formal"
}Path parameters
idintegerrequired- The inbound email id.
Body parameters
tone"formal" | "friendly" | "casual"defaultfriendly- Picks the salutation and sign-off:
Dear/Kind regards,,Hi/Thanks,orHey/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. threadingobjectin_reply_toandreferencesfor 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_fromis a mailbox with a signature; the suggested bodies then contain a{{signature}}tag where it goes.
Status codes
| Status | Meaning |
|---|---|
| 200 | The draft. |
| 400 | Invalid id or tone. |
| 401 | Missing, invalid or revoked API key. |
| 403 | The key lacks the required scope (insufficient_scope), or the team is suspended. |
| 404 | Inbound email not found. |
| 429 | More than 60 requests a minute on this endpoint. |
Other ways to read inbound mail#
| Tool | How |
|---|---|
| Dashboard | The Inbound page lists every stored message. |
| CLI | poststack inbound list|get|attachments|download|reply|forward |
| Python SDK | client.inbound_emails.list(), get(), list_attachments(), download_attachment(), draft_reply(), reply(), forward() |
| Go SDK | client.InboundEmails.List, Get, ListAttachments, DownloadAttachment, DraftReply, Reply, Forward |
| MCP | list_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.