SMTP relay
Send through PostStack from anything that speaks SMTP — a framework mailer, a CMS, a mail server — with the same tracking, suppression, webhooks and logs as the REST API.
Connection settings#
These are the only values you need. The same settings are on the API keys page of the dashboard, under SMTP configuration.
| Setting | Value |
|---|---|
| Host | smtp.poststack.dev |
| Port | 587 (STARTTLS or implicit TLS) or 465 (implicit TLS) |
| Encryption | Required. TLS 1.2 or 1.3, either from the first byte or via STARTTLS before you authenticate |
| Authentication | SMTP AUTH PLAIN or LOGIN |
| Username | poststack — any value is accepted when the password is an API key |
| Password | An API key, sk_live_… (or sk_test_… to simulate — see below) |
| Maximum message size | 36 MB (SIZE 37748736 in the EHLO reply) |
A mailbox can also sign in here with its full address and mailbox password instead of an API key — that is how Thunderbird, Apple Mail and Outlook send. The differences are listed under Mailbox credentials.
Ports and TLS#
Both ports land on the same relay; they differ only in how TLS starts. The relay looks at the first byte your client sends: a TLS ClientHello means implicit TLS, and a client that sends nothing and waits for a greeting is treated as STARTTLS.
| Port | How TLS starts | Use it when |
|---|---|---|
587 | STARTTLS (plaintext greeting, then upgrade) — or implicit TLS on connect | Almost always. Most mailers default to STARTTLS on 587. |
465 | Implicit TLS on connect (RFC 8314 “submissions”) | Your client has an “SSL/TLS” or “secure: true” switch rather than STARTTLS. |
25 | — | Never for sending. Port 25 is the inbound MX (inbound.poststack.dev): it does not accept AUTH and refuses to relay to outside domains. |
On a plaintext connection to 587 the relay answers only EHLO, HELO, STARTTLS and QUIT. Any other command — AUTH, MAIL FROM — is answered with 530 5.7.0 Must issue STARTTLS first, so a password can never cross the wire unencrypted. There is no unencrypted port and no port 2525 for submission.
The certificate is a Let's Encrypt certificate for poststack.dev that also names smtp.poststack.dev, so verification succeeds with the host above. After TLS the relay advertises AUTH PLAIN LOGIN, SIZE 37748736, 8BITMIME, SMTPUTF8 and PIPELINING.
Send your first message#
Verify the domain you send from
The address in
Frommust be on a domain that is verified on your team. A message from any other domain is refused at the end ofDATAwith550 Domain 'example.com' not found for this teamor… is not verified. See Domains.Create an API key
Any key works — Full access or Sending only. A key restricted to one domain can only send from that domain; anything else gets
550 API key is restricted to a different domain. See Authentication.Point your mailer at the relay
import nodemailer from 'nodemailer'; const transporter = nodemailer.createTransport({ host: 'smtp.poststack.dev', port: 587, secure: false, // start in plaintext… requireTLS: true, // …and refuse to continue unless STARTTLS succeeds auth: { user: 'poststack', // any value when the password is an API key pass: process.env.POSTSTACK_API_KEY, // sk_live_… }, }); // Implicit TLS instead: port: 465, secure: true (drop requireTLS). const info = await transporter.sendMail({ from: 'Acme <hello@yourdomain.com>', to: 'user@example.com', subject: 'Welcome to Acme', html: '<p>Thanks for signing up.</p>', text: 'Thanks for signing up.', headers: { 'X-PostStack-Tags': 'welcome,onboarding', }, }); console.log(info.response); // "250 OK: message queued"import os import smtplib from email.message import EmailMessage msg = EmailMessage() msg["From"] = "Acme <hello@yourdomain.com>" msg["To"] = "user@example.com" msg["Subject"] = "Welcome to Acme" msg["X-PostStack-Tags"] = "welcome,onboarding" msg.set_content("Thanks for signing up.") msg.add_alternative("<p>Thanks for signing up.</p>", subtype="html") # Port 587 + STARTTLS. For implicit TLS: smtplib.SMTP_SSL("smtp.poststack.dev", 465) with smtplib.SMTP("smtp.poststack.dev", 587) as server: server.starttls() server.login("poststack", os.environ["POSTSTACK_API_KEY"]) server.send_message(msg)package main import ( "net/smtp" "os" ) func main() { // net/smtp upgrades with STARTTLS when the server offers it, and PlainAuth // refuses to send credentials over a connection that is not encrypted. auth := smtp.PlainAuth("", "poststack", os.Getenv("POSTSTACK_API_KEY"), "smtp.poststack.dev") msg := []byte("From: Acme <hello@yourdomain.com>\r\n" + "To: user@example.com\r\n" + "Subject: Welcome to Acme\r\n" + "X-PostStack-Tags: welcome,onboarding\r\n" + "Content-Type: text/plain; charset=utf-8\r\n" + "\r\n" + "Thanks for signing up.\r\n") err := smtp.SendMail("smtp.poststack.dev:587", auth, "hello@yourdomain.com", []string{"user@example.com"}, msg) if err != nil { panic(err) } }Find it in the dashboard
A live send is answered
250 OK: message queued; the email id is not part of that reply. The message appears under Emails within a second, with the same statuses and webhook events as an API send. Tag it withX-PostStack-Tagsto find it again.
Framework settings#
Every framework mailer needs the same four values. These use port 587 with STARTTLS and read the key from an environment variable.
# settings.py
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.poststack.dev"
EMAIL_PORT = 587
EMAIL_USE_TLS = True # STARTTLS. For port 465 use EMAIL_USE_SSL = True instead.
EMAIL_HOST_USER = "poststack"
EMAIL_HOST_PASSWORD = os.environ["POSTSTACK_API_KEY"]
DEFAULT_FROM_EMAIL = "Acme <hello@yourdomain.com>"# config/environments/production.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
address: "smtp.poststack.dev",
port: 587,
user_name: "poststack",
password: ENV["POSTSTACK_API_KEY"],
authentication: :plain,
enable_starttls_auto: true
}# .env — Symfony Mailer upgrades with STARTTLS on 587 by itself
MAIL_MAILER=smtp
MAIL_HOST=smtp.poststack.dev
MAIL_PORT=587
MAIL_USERNAME=poststack
MAIL_PASSWORD=sk_live_...
MAIL_FROM_ADDRESS=hello@yourdomain.comStep-by-step guides for these and other stacks are under Framework guides — for example Django, Laravel and Ruby.
PostStack headers#
Add these headers to a message to use API features over SMTP. They are read as instructions and removed; the recipient never sees them. Header names are case-insensitive.
X-PostStack-Tagsstring- Comma-separated tags, e.g.
welcome,onboarding. Whitespace around each tag is trimmed and empty entries are dropped. Same as the API'stags. X-PostStack-Idempotency-Keystring- A key that makes a retry safe. If your team has already sent a message with this key, the relay answers
250and sends nothing new. Over SMTP a repeated key is always treated as a replay, even if the message content changed — unlike the HTTP API, which answers422for a different payload. Same asidempotency_key. X-PostStack-Template-Idstring- The
tpl_…id of a published template. Its subject, HTML and text replace the ones in your message. An unknown id is refused with550 Template not found, an unpublished one with550 Template is not published. See Templates. X-PostStack-Variablesstring (JSON object)- The template variables as one line of JSON, e.g.
{"first_name":"Alice"}. JSON that does not parse is ignored without an error, and the template renders with no variables — check the rendered email if a placeholder comes out empty. X-PostStack-Schedulestring (ISO 8601)- Deliver later instead of now, e.g.
2026-10-01T09:00:00Z. Include a timezone offset orZ. Same asscheduled_at.
await transporter.sendMail({
from: 'Acme <hello@yourdomain.com>',
to: 'user@example.com',
// subject, html and text are replaced by the rendered template
headers: {
'X-PostStack-Template-Id': 'tpl_abc123def456ghi789',
'X-PostStack-Variables': JSON.stringify({ first_name: 'Alice', company_name: 'Acme' }),
'X-PostStack-Idempotency-Key': 'welcome-user-4821',
'X-PostStack-Schedule': '2026-10-01T09:00:00Z',
},
});How your message is processed#
PostStack is a submission relay, not a byte-for-byte forwarder. Your message is parsed into its parts — addresses, subject, HTML and text bodies, attachments — recorded as an email you can see in the dashboard, and composed again on the way out. That is what makes tracking, templates, suppression and webhooks work over SMTP, and it has these consequences:
- Recipients.
ToandCccome from the message headers. Any envelope recipient (RCPT TO) that is in neither becomes a Bcc. If the message has noToheader, the envelope recipients are used. The sender is theFromheader, display name included. - Your DKIM signature is replaced. If your server already signs (Mailcow, Postfix with OpenDKIM, Microsoft 365), that signature cannot survive recomposition — it covers the exact bytes. PostStack signs the outgoing message with the key it holds for your verified domain, so it still leaves with a valid
d=yourdomain.comsignature that aligns for DMARC. There is no passthrough mode. - Only some headers are carried. Headers starting with
X-(other than the PostStack ones) are passed through, andIn-Reply-ToandReferencesare kept so replies thread. A very longReferenceschain is trimmed to 1,024 characters, keeping the first id and the most recent ones. Every other header is dropped, and PostStack sets its ownMessage-IDandDate. - Tracking changes your HTML. Open tracking adds a pixel and click tracking rewrites links. Both are on by default and can be turned off per domain — see Tracking.
- Inline images stay inline. Parts referenced from the HTML with
cid:are kept as inline attachments, not converted todata:URIs (which Gmail and Outlook strip). - No mailbox signature. A mailbox signature set in PostStack is never added to SMTP submissions, because your mail client has already added its own.
Limits#
| Limit | Value | What happens past it |
|---|---|---|
| Message size | 36 MB raw (after MIME encoding) | Refused with 552 during DATA |
| Attachment size | 10 MB per file, 25 MB per message, measured decoded | 550 naming the file and the limit |
| Recipients | 50 per message, across To, Cc and Bcc | 550 Too many recipients: … |
| Monthly email quota | Your plan’s allowance | 451 Monthly email limit reached. Upgrade your plan. (temporary, so your server keeps the message and retries) |
| Failed logins | 10 per 15 minutes per IP address or username | Further attempts get 535 Too many failed attempts. Try again later. until the window passes |
| Mailbox sending rate | 60 messages per hour per mailbox (mailbox credentials only) | 450 Rate limit exceeded. Try again later. |
Attachments are counted after decoding, so a 10 MB file is 10 MB here even though it is about 13.5 MB inside the message. API-key sends have no per-connection rate limit beyond your plan quota.
Replies and error codes#
Authentication is checked at AUTH; everything else — sender domain, template, quota, suppression — is checked once the whole message has arrived, and the answer is the reply to the final . of DATA. A 5xx is permanent: fix the message rather than retrying. A 4xx is temporary and a mail server will retry it on its own.
| Reply | Meaning |
|---|---|
250 OK: message queued | Accepted for delivery (or scheduled). |
250 Accepted em_… in TEST MODE … | Accepted with a test key. Nothing is delivered. |
530 5.7.0 Must issue STARTTLS first | You sent AUTH or MAIL on a plaintext connection to 587. |
535 Invalid API key | The key is wrong, revoked or expired. |
535 Invalid email or password | The password was not an API key, and no active mailbox matched the address and password. |
550 From address must be … | Mailbox credentials can only send as that mailbox’s own address. |
550 Domain … not found for this team | The From domain is not on your team, or … is not verified. |
550 All recipients are suppressed | Every recipient — or every To recipient — is on your suppression list. Otherwise suppressed recipients are dropped and the rest is sent. |
550 Verify your email address before sending email. | The team owner has not confirmed their email address yet. |
451 Monthly email limit reached. Upgrade your plan. | Your plan’s quota for this month is used up. |
450 … | A fault on our side, a busy server or the mailbox rate limit. Retry later. |
Mailbox credentials#
When the password is not an API key, the relay treats the username as a mailbox address and checks the mailbox password. This is what a desktop or phone mail client uses. Compared with an API key:
- The username must be the full address, e.g.
anna@yourdomain.com, and the mailbox must be active. - The
Fromaddress must be exactly that mailbox; anything else gets550 From address must be anna@yourdomain.com. - A copy of each sent message is saved to the mailbox's Sent folder.
- Sending is limited to 60 messages per hour per mailbox.
- Sends are always live — there is no test mode for mailbox credentials.
Test the connection#
To separate a network or credentials problem from a problem in your application, send one message from a terminal with swaks:
swaks --server smtp.poststack.dev --port 587 --tls \
--auth PLAIN --auth-user poststack --auth-password "$POSTSTACK_API_KEY" \
--from hello@yourdomain.com --to you@example.com \
--header "Subject: PostStack SMTP test"Or inspect the TLS handshake and capabilities without sending:
# See the certificate and the server's capabilities without sending anything.
openssl s_client -connect smtp.poststack.dev:587 -starttls smtp -quiet
# then type: EHLO test.example → 250-AUTH PLAIN LOGIN … 250 SIZE 37748736
# QUITTroubleshooting#
The connection times out, or hangs right after connecting
Many hosting providers and cloud platforms block outbound SMTP ports. Check that your server can reach smtp.poststack.dev on 587 or 465 at all (the openssl command above). On a plaintext connection to 587 the greeting arrives about 1.5 seconds after connecting — the relay waits that long to see whether your client starts TLS — so a client-side greeting timeout shorter than that will fail.
“wrong version number” or a TLS handshake error
Your client and port disagree about how TLS starts. With nodemailer, secure: true means implicit TLS: use it with 465, and secure: false with 587. In Python use SMTP_SSL on 465 and SMTP + starttls() on 587.
530 5.7.0 Must issue STARTTLS first
Your client connected to 587 and tried to authenticate without upgrading. Turn on STARTTLS (requireTLS: true, EMAIL_USE_TLS = True, enable_starttls_auto: true) or switch to 465 with implicit TLS.
421 You talk too soon
A hand-written client sent EHLO before reading the server's 220 greeting. Wait for the greeting first; every SMTP library already does.
The send succeeds but nothing arrives
Check the reply text: if it says TEST MODE, you are using an sk_test_… key. Otherwise open the email under Emails in the dashboard — its events show whether it was delivered, bounced or suppressed.
The recipient sees a PostStack Message-ID, or my custom header is missing
Expected: only X- headers and the threading headers are carried through, and the outgoing message gets a new Message-ID. Rename a custom header to start with X- to have it delivered.