Postmark - Kompletny Przewodnik po Email Delivery API
Czym jest Postmark?
Postmark to wyspecjalizowany serwis email API stworzony przez Wildbit (obecnie część ActiveCampaign), skoncentrowany wyłącznie na transactional emails - automatycznych wiadomościach wysyłanych w odpowiedzi na akcje użytkownika: rejestracje, resety haseł, potwierdzenia zamówień, powiadomienia i faktury.
W przeciwieństwie do platform all-in-one jak SendGrid czy Mailgun, Postmark celowo nie obsługuje email marketingu. Ta specjalizacja pozwala im osiągać najwyższą deliverability w branży - ponad 99% wiadomości trafia do inbox, nie do spamu.
Postmark wyróżnia się przejrzystością - publikują statystyki deliverability w czasie rzeczywistym, a ich infrastruktura jest zoptymalizowana pod szybkość dostarczania (średnio <10 sekund od wysłania do inbox).
Dlaczego Postmark?
Kluczowe zalety Postmark
- 99%+ Inbox Rate - Najlepsza deliverability w branży
- Szybka dostawa - Średnio <10 sekund do inbox
- Transactional focus - Brak marketingowego spamu na infrastrukturze
- Templates - Visual editor i MJML support
- Message Streams - Oddzielne strumienie dla różnych typów maili
- Inbound Email - Odbieranie i parsowanie przychodzących maili
- Webhooks - Real-time notifications o dostarczeniu, otwarciach, kliknięciach
- Bounce handling - Automatyczne zarządzanie odrzuconymi adresami
Postmark vs Inne Email Services
| Cecha | Postmark | SendGrid | Mailgun | Amazon SES |
|---|---|---|---|---|
| Fokus | Transactional only | All-in-one | All-in-one | Infrastructure |
| Deliverability | 99%+ | 95-98% | 95-98% | Varies |
| Czas dostawy | <10s | 30s-2min | 30s-2min | Varies |
| Cena (10K/mo) | $15 | $20 | $15 | $1 |
| Templates | ✅ Visual + MJML | ✅ | ✅ | ❌ |
| Inbound email | ✅ | ✅ | ✅ | ✅ |
| Marketing email | ❌ Nie | ✅ | ✅ | ✅ |
| Analytics | Szczegółowe | Podstawowe | Szczegółowe | Podstawowe |
| Support | Świetny | Dobry | Dobry | Self-service |
Kiedy wybrać Postmark?
Postmark jest idealny gdy:
- Wysyłasz transactional emails (auth, notifications, receipts)
- Deliverability jest krytyczna (SaaS, e-commerce)
- Potrzebujesz szybkiej dostawy (<10s)
- Chcesz profesjonalne templates bez kodowania
- Zależy Ci na szczegółowej analityce
Rozważ alternatywy gdy:
- Potrzebujesz email marketingu → SendGrid, Mailchimp
- Budżet jest minimalny → Amazon SES
- Wysyłasz >1M maili/miesiąc → SendGrid, własna infrastruktura
- Potrzebujesz all-in-one platform → SendGrid, Brevo
Pierwsze Kroki
Utworzenie konta
- Zarejestruj się na postmarkapp.com
- Zweryfikuj domenę nadawcy
- Dodaj rekordy DNS (DKIM, Return-Path)
- Pobierz Server API Token z dashboardu
DNS Configuration
# DKIM Record
Name: pm._domainkey.yourdomain.com
Type: TXT
Value: k=rsa;p=MIGf... (z dashboardu Postmark)
# Return-Path (bounce handling)
Name: pm-bounces.yourdomain.com
Type: CNAME
Value: pm.mtasv.netInstalacja SDK
# Node.js
npm install postmark
# Python
pip install postmarker
# Ruby
gem install postmark
# PHP
composer require wildbit/postmark-phpKonfiguracja klienta
// lib/postmark.ts
import { ServerClient } from 'postmark'
// Singleton client
export const postmarkClient = new ServerClient(
process.env.POSTMARK_SERVER_TOKEN!
)
// Dla wielu serwerów (różne projekty)
import { AccountClient } from 'postmark'
export const accountClient = new AccountClient(
process.env.POSTMARK_ACCOUNT_TOKEN!
)# .env.local
POSTMARK_SERVER_TOKEN=your-server-api-token
POSTMARK_ACCOUNT_TOKEN=your-account-api-tokenWysyłanie Emaili
Prosty email
import { postmarkClient } from '@/lib/postmark'
// Wysyłka pojedynczego emaila
await postmarkClient.sendEmail({
From: 'noreply@yourdomain.com',
To: 'user@example.com',
Subject: 'Witaj w naszej aplikacji!',
TextBody: 'Dziękujemy za rejestrację. Twoje konto jest aktywne.',
HtmlBody: `
<h1>Witaj w naszej aplikacji!</h1>
<p>Dziękujemy za rejestrację. Twoje konto jest aktywne.</p>
<a href="https://app.example.com">Zaloguj się</a>
`,
Tag: 'welcome',
TrackOpens: true,
TrackLinks: 'HtmlAndText',
Metadata: {
userId: '12345',
source: 'registration',
},
})Z załącznikami
import fs from 'fs'
import path from 'path'
await postmarkClient.sendEmail({
From: 'invoices@yourdomain.com',
To: 'customer@example.com',
Subject: 'Twoja faktura #12345',
HtmlBody: '<p>W załączniku faktura.</p>',
Attachments: [
{
Name: 'faktura-12345.pdf',
Content: fs.readFileSync(
path.join(process.cwd(), 'invoices', '12345.pdf')
).toString('base64'),
ContentType: 'application/pdf',
},
{
Name: 'logo.png',
Content: logoBase64,
ContentType: 'image/png',
ContentID: 'cid:logo', // Inline image
},
],
})Reply-To i CC/BCC
await postmarkClient.sendEmail({
From: 'noreply@yourdomain.com',
ReplyTo: 'support@yourdomain.com',
To: 'user@example.com',
Cc: 'manager@example.com',
Bcc: 'archive@yourdomain.com',
Subject: 'Twoje zapytanie',
TextBody: 'Odpowiemy wkrótce.',
})Templates
Postmark oferuje system templates z visual editorem i zmiennymi.
Tworzenie template w dashboardzie
- Servers → Your Server → Templates → New Template
- Wybierz typ: Basic, Welcome, Password Reset, Receipt, itd.
- Edytuj w visual editorze lub kodzie HTML/MJML
- Dodaj zmienne:
{{ variable_name }}
Wysyłanie z template
// Używając Template Alias
await postmarkClient.sendEmailWithTemplate({
From: 'noreply@yourdomain.com',
To: 'user@example.com',
TemplateAlias: 'welcome-email', // Alias z dashboardu
TemplateModel: {
name: 'Jan',
product_name: 'MyApp',
action_url: 'https://app.example.com/verify?token=abc123',
support_email: 'support@example.com',
company_name: 'Example Inc.',
company_address: 'ul. Przykładowa 1, Warszawa',
},
Tag: 'welcome',
TrackOpens: true,
})
// Używając Template ID
await postmarkClient.sendEmailWithTemplate({
From: 'noreply@yourdomain.com',
To: 'user@example.com',
TemplateId: 12345678,
TemplateModel: {
// ...
},
})Przykładowy template (MJML)
<!-- W dashboardzie Postmark lub lokalnie -->
<mjml>
<mj-head>
<mj-title>{{ subject }}</mj-title>
<mj-attributes>
<mj-all font-family="Arial, sans-serif" />
<mj-text font-size="14px" line-height="1.6" color="#333333" />
</mj-attributes>
</mj-head>
<mj-body background-color="#f4f4f4">
<mj-section background-color="#ffffff" padding="20px">
<mj-column>
<mj-image src="{{ logo_url }}" width="150px" />
</mj-column>
</mj-section>
<mj-section background-color="#ffffff" padding="20px">
<mj-column>
<mj-text font-size="24px" font-weight="bold">
Cześć {{ name }}!
</mj-text>
<mj-text>
{{ message }}
</mj-text>
<mj-button
background-color="#007bff"
href="{{ action_url }}"
font-size="16px"
padding="15px 30px"
>
{{ action_text }}
</mj-button>
</mj-column>
</mj-section>
<mj-section background-color="#f4f4f4" padding="20px">
<mj-column>
<mj-text font-size="12px" color="#666666" align="center">
© {{ current_year }} {{ company_name }}
<br />
{{ company_address }}
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>React Email Integration
npm install @react-email/components react-email// emails/WelcomeEmail.tsx
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
} from '@react-email/components'
interface WelcomeEmailProps {
name: string
actionUrl: string
}
export function WelcomeEmail({ name, actionUrl }: WelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Witaj w naszej aplikacji!</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Cześć {name}!</Heading>
<Text style={text}>
Dziękujemy za rejestrację. Kliknij poniższy przycisk, aby aktywować konto.
</Text>
<Section style={buttonContainer}>
<Button style={button} href={actionUrl}>
Aktywuj konto
</Button>
</Section>
</Container>
</Body>
</Html>
)
}
const main = {
backgroundColor: '#f6f9fc',
fontFamily: 'Arial, sans-serif',
}
const container = {
backgroundColor: '#ffffff',
margin: '0 auto',
padding: '40px',
borderRadius: '4px',
}
const h1 = {
color: '#333',
fontSize: '24px',
}
const text = {
color: '#666',
fontSize: '16px',
lineHeight: '24px',
}
const buttonContainer = {
textAlign: 'center' as const,
marginTop: '24px',
}
const button = {
backgroundColor: '#007bff',
color: '#fff',
padding: '12px 24px',
borderRadius: '4px',
textDecoration: 'none',
}
export default WelcomeEmail// lib/send-email.ts
import { render } from '@react-email/render'
import { postmarkClient } from './postmark'
import WelcomeEmail from '@/emails/WelcomeEmail'
export async function sendWelcomeEmail(to: string, name: string, actionUrl: string) {
const html = await render(WelcomeEmail({ name, actionUrl }))
const text = await render(WelcomeEmail({ name, actionUrl }), { plainText: true })
await postmarkClient.sendEmail({
From: 'noreply@yourdomain.com',
To: to,
Subject: 'Witaj w naszej aplikacji!',
HtmlBody: html,
TextBody: text,
Tag: 'welcome',
})
}Batch Sending
Wysyłanie wielu emaili w jednym request (do 500).
// Batch z tym samym template
const users = [
{ email: 'user1@example.com', name: 'Jan' },
{ email: 'user2@example.com', name: 'Anna' },
{ email: 'user3@example.com', name: 'Piotr' },
]
const messages = users.map((user) => ({
From: 'noreply@yourdomain.com',
To: user.email,
TemplateAlias: 'weekly-digest',
TemplateModel: {
name: user.name,
date: new Date().toLocaleDateString('pl-PL'),
},
}))
const results = await postmarkClient.sendEmailBatchWithTemplates(messages)
// Sprawdź wyniki
results.forEach((result, index) => {
if (result.ErrorCode !== 0) {
console.error(`Failed to send to ${users[index].email}:`, result.Message)
}
})// Batch bez templates
const notifications = [
{ to: 'user1@example.com', message: 'Notification 1' },
{ to: 'user2@example.com', message: 'Notification 2' },
]
const messages = notifications.map((n) => ({
From: 'noreply@yourdomain.com',
To: n.to,
Subject: 'Nowa notyfikacja',
TextBody: n.message,
}))
await postmarkClient.sendEmailBatch(messages)Message Streams
Message Streams pozwalają oddzielić różne typy emaili dla lepszej deliverability.
Typy streamów
- Transactional (domyślny) - auth, notifications, receipts
- Broadcast - newsletters, marketing (wymaga osobnego streamu)
- Inbound - odbieranie emaili
Wysyłanie do konkretnego streamu
// Transactional (domyślny)
await postmarkClient.sendEmail({
From: 'noreply@yourdomain.com',
To: 'user@example.com',
MessageStream: 'outbound', // domyślny transactional
Subject: 'Reset hasła',
TextBody: 'Kliknij link, aby zresetować hasło.',
})
// Broadcast (newsletter)
await postmarkClient.sendEmail({
From: 'newsletter@yourdomain.com',
To: 'subscriber@example.com',
MessageStream: 'broadcast', // broadcast stream
Subject: 'Weekly Newsletter',
HtmlBody: '<h1>This week...</h1>',
})Tworzenie nowego streamu
import { AccountClient } from 'postmark'
const accountClient = new AccountClient(process.env.POSTMARK_ACCOUNT_TOKEN!)
await accountClient.createMessageStream({
ID: 'product-updates',
Name: 'Product Updates',
MessageStreamType: 'Broadcasts',
ServerId: 12345,
})Webhooks
Real-time notifications o wydarzeniach email.
Konfiguracja webhooks w dashboardzie
Servers → Your Server → Webhooks → Add Webhook
Dostępne eventy:
- Delivery - Email dostarczony
- Bounce - Email odrzucony
- SpamComplaint - Oznaczony jako spam
- Open - Email otwarty
- Click - Link kliknięty
- SubscriptionChange - Unsubscribe
Webhook handler w Next.js
// app/api/postmark-webhook/route.ts
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'
interface PostmarkWebhookEvent {
RecordType: 'Delivery' | 'Bounce' | 'SpamComplaint' | 'Open' | 'Click' | 'SubscriptionChange'
MessageID: string
Recipient: string
DeliveredAt?: string
BouncedAt?: string
Type?: string
TypeCode?: number
Name?: string
Tag?: string
Description?: string
Details?: string
Email?: string
From?: string
Subject?: string
Metadata?: Record<string, string>
// For Open events
FirstOpen?: boolean
Platform?: string
ReadSeconds?: number
// For Click events
OriginalLink?: string
ClickLocation?: string
}
export async function POST(request: NextRequest) {
const body = await request.text()
// Opcjonalna weryfikacja signature (jeśli włączona)
const signature = request.headers.get('X-Postmark-Signature')
if (signature) {
const expectedSignature = crypto
.createHmac('sha256', process.env.POSTMARK_WEBHOOK_SECRET!)
.update(body)
.digest('base64')
if (signature !== expectedSignature) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
}
const event: PostmarkWebhookEvent = JSON.parse(body)
try {
switch (event.RecordType) {
case 'Delivery':
console.log(`Email delivered to ${event.Recipient}`)
// Update delivery status in database
await updateEmailStatus(event.MessageID, 'delivered', event.DeliveredAt)
break
case 'Bounce':
console.log(`Bounce from ${event.Recipient}: ${event.Description}`)
// Handle bounce - mark email as invalid
await handleBounce(event.Recipient, event.TypeCode, event.Description)
// Hard bounces (TypeCode 1) - permanently invalid
if (event.TypeCode === 1) {
await markEmailInvalid(event.Recipient)
}
break
case 'SpamComplaint':
console.log(`Spam complaint from ${event.Email}`)
// Immediately unsubscribe user
await unsubscribeUser(event.Email)
await logSpamComplaint(event.Email, event.Subject)
break
case 'Open':
console.log(`Email opened by ${event.Recipient}`)
// Track engagement
await trackEmailOpen(event.MessageID, {
firstOpen: event.FirstOpen,
platform: event.Platform,
readSeconds: event.ReadSeconds,
})
break
case 'Click':
console.log(`Link clicked in email to ${event.Recipient}: ${event.OriginalLink}`)
// Track click
await trackEmailClick(event.MessageID, event.OriginalLink)
break
case 'SubscriptionChange':
console.log(`Subscription change for ${event.Recipient}`)
// Handle unsubscribe
await handleUnsubscribe(event.Recipient, event.Metadata)
break
}
return NextResponse.json({ received: true })
} catch (error) {
console.error('Webhook processing error:', error)
return NextResponse.json({ error: 'Processing failed' }, { status: 500 })
}
}
// Helper functions
async function updateEmailStatus(messageId: string, status: string, timestamp?: string) {
// Update in your database
await prisma.emailLog.update({
where: { messageId },
data: { status, deliveredAt: timestamp },
})
}
async function handleBounce(email: string, typeCode?: number, description?: string) {
await prisma.emailBounce.create({
data: {
email,
bounceType: typeCode === 1 ? 'hard' : 'soft',
description,
bouncedAt: new Date(),
},
})
}
async function markEmailInvalid(email: string) {
await prisma.user.update({
where: { email },
data: { emailValid: false },
})
}
async function unsubscribeUser(email: string) {
await prisma.user.update({
where: { email },
data: { marketingOptIn: false, unsubscribedAt: new Date() },
})
}Bounce Handling
Postmark automatycznie zarządza bounce'ami i suppression listą.
Typy bounce'ów
| Type Code | Nazwa | Opis | Akcja |
|---|---|---|---|
| 1 | HardBounce | Adres nie istnieje | Usuń z listy |
| 2 | Transient | Tymczasowy problem | Retry później |
| 16 | Unsubscribe | User wypisał się | Oznacz unsubscribed |
| 32 | Subscribe | User zapisał się ponownie | Włącz powiadomienia |
| 512 | SpamComplaint | Spam report | Natychmiast usuń |
Pobieranie bounce'ów przez API
// Pobierz ostatnie bounce'y
const bounces = await postmarkClient.getBounces({
count: 100,
offset: 0,
type: 'HardBounce', // Opcjonalne filtrowanie
// inactive: true, // Tylko nieaktywne
// emailFilter: '@example.com', // Filtruj po domenie
// tag: 'welcome', // Filtruj po tagu
})
bounces.Bounces.forEach((bounce) => {
console.log(`Bounce: ${bounce.Email} - ${bounce.Type} - ${bounce.Description}`)
})
// Aktywuj ponownie bounce (jeśli naprawiony)
await postmarkClient.activateBounce(bounceId)
// Pobierz dump wszystkich bounceów
const dump = await postmarkClient.getBounceDump(bounceId)Sprawdzanie przed wysyłką
// Sprawdź czy email jest na suppression list
async function canSendTo(email: string): Promise<boolean> {
try {
const suppressions = await postmarkClient.getSuppressions('outbound')
return !suppressions.Suppressions.some(
(s) => s.EmailAddress.toLowerCase() === email.toLowerCase()
)
} catch {
return true // W razie błędu, pozwól wysłać
}
}
// Przed wysyłką
if (await canSendTo(userEmail)) {
await postmarkClient.sendEmail({
// ...
})
} else {
console.log(`Email ${userEmail} is suppressed, skipping`)
}Inbound Email
Odbieranie i parsowanie przychodzących emaili.
Konfiguracja
- Servers → Your Server → Settings → Inbound
- Ustaw Inbound domain (np.
inbound.yourdomain.com) - Skonfiguruj DNS MX record:CodeTEXT
MX 10 inbound.postmarkapp.com - Ustaw webhook URL dla inbound emails
Webhook handler dla inbound
// app/api/postmark-inbound/route.ts
import { NextRequest, NextResponse } from 'next/server'
interface PostmarkInboundEmail {
From: string
FromName: string
FromFull: {
Email: string
Name: string
}
To: string
ToFull: Array<{
Email: string
Name: string
}>
Cc: string
CcFull: Array<{
Email: string
Name: string
}>
ReplyTo: string
Subject: string
MessageID: string
Date: string
TextBody: string
HtmlBody: string
StrippedTextReply: string
Tag: string
Headers: Array<{
Name: string
Value: string
}>
Attachments: Array<{
Name: string
Content: string // Base64
ContentType: string
ContentLength: number
ContentID?: string
}>
}
export async function POST(request: NextRequest) {
const email: PostmarkInboundEmail = await request.json()
console.log(`Received email from ${email.From}: ${email.Subject}`)
// Parse email address patterns
const toAddress = email.ToFull[0].Email
// Support ticket pattern: support+ticket123@inbound.yourdomain.com
const ticketMatch = toAddress.match(/support\+ticket(\d+)@/)
if (ticketMatch) {
const ticketId = ticketMatch[1]
await addReplyToTicket(ticketId, {
from: email.From,
subject: email.Subject,
body: email.StrippedTextReply || email.TextBody,
attachments: email.Attachments,
})
return NextResponse.json({ processed: true, action: 'ticket_reply' })
}
// New support request
if (toAddress.startsWith('support@')) {
const ticketId = await createSupportTicket({
from: email.From,
fromName: email.FromName,
subject: email.Subject,
body: email.TextBody,
htmlBody: email.HtmlBody,
attachments: email.Attachments,
})
return NextResponse.json({ processed: true, action: 'ticket_created', ticketId })
}
// Unknown pattern
return NextResponse.json({ processed: false, reason: 'unknown_address' })
}
async function addReplyToTicket(ticketId: string, data: any) {
// Save to database
await prisma.ticketReply.create({
data: {
ticketId,
fromEmail: data.from,
content: data.body,
attachments: {
create: data.attachments.map((a: any) => ({
name: a.Name,
contentType: a.ContentType,
size: a.ContentLength,
data: Buffer.from(a.Content, 'base64'),
})),
},
},
})
// Notify assignee
await notifyTicketAssignee(ticketId)
}
async function createSupportTicket(data: any) {
const ticket = await prisma.supportTicket.create({
data: {
email: data.from,
name: data.fromName,
subject: data.subject,
content: data.body,
status: 'open',
},
})
// Send auto-reply
await postmarkClient.sendEmailWithTemplate({
From: 'support@yourdomain.com',
To: data.from,
TemplateAlias: 'ticket-received',
TemplateModel: {
ticket_id: ticket.id,
subject: data.subject,
},
})
return ticket.id
}Email Analytics
Pobieranie statystyk
// Statystyki ogólne
const stats = await postmarkClient.getOutboundOverview({
tag: 'welcome', // Opcjonalnie
fromDate: '2024-01-01',
toDate: '2024-01-31',
})
console.log(`Sent: ${stats.Sent}`)
console.log(`Bounced: ${stats.Bounced}`)
console.log(`SMTPAPIErrors: ${stats.SMTPAPIErrors}`)
console.log(`BounceRate: ${stats.BounceRate}`)
console.log(`SpamComplaints: ${stats.SpamComplaints}`)
console.log(`SpamComplaintsRate: ${stats.SpamComplaintsRate}`)
console.log(`Opens: ${stats.Opens}`)
console.log(`UniqueOpens: ${stats.UniqueOpens}`)
console.log(`Clicks: ${stats.Clicks}`)
console.log(`UniqueLinksClicked: ${stats.UniqueLinksClicked}`)
// Statystyki dzienne
const dailyStats = await postmarkClient.getOutboundSendCounts({
tag: 'welcome',
fromDate: '2024-01-01',
toDate: '2024-01-31',
})
dailyStats.Days.forEach((day) => {
console.log(`${day.Date}: ${day.Sent} sent`)
})
// Statystyki bounce'ów
const bounceStats = await postmarkClient.getOutboundBounceCounts({
fromDate: '2024-01-01',
toDate: '2024-01-31',
})
// Statystyki kliknięć
const clickStats = await postmarkClient.getOutboundClickCounts({
fromDate: '2024-01-01',
toDate: '2024-01-31',
})
// Statystyki platform (urządzenia, klienty email)
const platformStats = await postmarkClient.getOutboundOpenPlatforms({
fromDate: '2024-01-01',
toDate: '2024-01-31',
})
platformStats.Days.forEach((day) => {
console.log(`${day.Date}:`)
console.log(` Desktop: ${day.Desktop}`)
console.log(` Mobile: ${day.Mobile}`)
console.log(` WebMail: ${day.WebMail}`)
})Śledzenie pojedynczych wiadomości
// Pobierz szczegóły konkretnego emaila
const message = await postmarkClient.getOutboundMessage(messageId)
console.log(`To: ${message.Recipients}`)
console.log(`Subject: ${message.Subject}`)
console.log(`Status: ${message.Status}`)
console.log(`ReceivedAt: ${message.ReceivedAt}`)
// Lista ostatnich wiadomości
const messages = await postmarkClient.getOutboundMessages({
count: 50,
offset: 0,
recipient: 'user@example.com', // Filtruj po odbiorcy
tag: 'password-reset', // Filtruj po tagu
status: 'sent', // sent, queued, processed
// fromDate: '2024-01-01',
// toDate: '2024-01-31',
})
messages.Messages.forEach((msg) => {
console.log(`${msg.MessageID}: ${msg.Subject} -> ${msg.Recipients}`)
})Error Handling
import { Errors } from 'postmark'
async function sendEmailSafely(to: string, subject: string, body: string) {
try {
const result = await postmarkClient.sendEmail({
From: 'noreply@yourdomain.com',
To: to,
Subject: subject,
TextBody: body,
})
return { success: true, messageId: result.MessageID }
} catch (error) {
if (error instanceof Errors.PostmarkError) {
switch (error.code) {
case 300: // Invalid email request
console.error('Invalid email format:', error.message)
return { success: false, error: 'invalid_email' }
case 406: // Inactive recipient
console.error('Recipient is inactive (bounced/unsubscribed):', to)
return { success: false, error: 'inactive_recipient' }
case 409: // JSON required
case 422: // Invalid JSON
console.error('Invalid request format:', error.message)
return { success: false, error: 'invalid_request' }
case 429: // Rate limit
console.error('Rate limit exceeded, retry later')
return { success: false, error: 'rate_limit', retryAfter: 60 }
case 500: // Postmark server error
case 503: // Service unavailable
console.error('Postmark service error, retry later')
return { success: false, error: 'service_error', retryAfter: 300 }
default:
console.error('Postmark error:', error.code, error.message)
return { success: false, error: 'unknown' }
}
}
throw error
}
}Retry logic
async function sendEmailWithRetry(
emailData: any,
maxRetries = 3,
baseDelay = 1000
) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await postmarkClient.sendEmail(emailData)
} catch (error) {
if (error instanceof Errors.PostmarkError) {
// Don't retry client errors
if (error.code >= 400 && error.code < 500 && error.code !== 429) {
throw error
}
// Last attempt
if (attempt === maxRetries) {
throw error
}
// Exponential backoff
const delay = baseDelay * Math.pow(2, attempt - 1)
console.log(`Retry attempt ${attempt}/${maxRetries} in ${delay}ms`)
await new Promise((resolve) => setTimeout(resolve, delay))
} else {
throw error
}
}
}
}Integracja z Next.js
Server Actions
// app/actions/email.ts
'use server'
import { postmarkClient } from '@/lib/postmark'
import { render } from '@react-email/render'
import WelcomeEmail from '@/emails/WelcomeEmail'
import PasswordResetEmail from '@/emails/PasswordResetEmail'
export async function sendWelcomeEmail(userId: string) {
const user = await prisma.user.findUnique({ where: { id: userId } })
if (!user) throw new Error('User not found')
const verificationUrl = `${process.env.NEXT_PUBLIC_APP_URL}/verify?token=${user.verificationToken}`
const html = await render(WelcomeEmail({
name: user.name,
actionUrl: verificationUrl,
}))
await postmarkClient.sendEmail({
From: 'noreply@yourdomain.com',
To: user.email,
Subject: 'Witaj! Potwierdź swój email',
HtmlBody: html,
Tag: 'welcome',
Metadata: { userId: user.id },
})
}
export async function sendPasswordResetEmail(email: string) {
const user = await prisma.user.findUnique({ where: { email } })
if (!user) return // Don't reveal if user exists
const token = generateSecureToken()
await prisma.passwordReset.create({
data: {
userId: user.id,
token,
expiresAt: new Date(Date.now() + 3600000), // 1 hour
},
})
const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password?token=${token}`
const html = await render(PasswordResetEmail({
name: user.name,
resetUrl,
expiresIn: '1 godzinę',
}))
await postmarkClient.sendEmail({
From: 'noreply@yourdomain.com',
To: email,
Subject: 'Reset hasła',
HtmlBody: html,
Tag: 'password-reset',
Metadata: { userId: user.id },
})
}Route Handlers
// app/api/send-email/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { postmarkClient } from '@/lib/postmark'
import { z } from 'zod'
const sendEmailSchema = z.object({
to: z.string().email(),
subject: z.string().min(1).max(200),
body: z.string().min(1),
template: z.string().optional(),
})
export async function POST(request: NextRequest) {
// Verify authentication
const session = await getServerSession()
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = sendEmailSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues }, { status: 400 })
}
const { to, subject, body: emailBody, template } = parsed.data
try {
if (template) {
await postmarkClient.sendEmailWithTemplate({
From: 'noreply@yourdomain.com',
To: to,
TemplateAlias: template,
TemplateModel: { subject, body: emailBody },
})
} else {
await postmarkClient.sendEmail({
From: 'noreply@yourdomain.com',
To: to,
Subject: subject,
TextBody: emailBody,
})
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Email send error:', error)
return NextResponse.json({ error: 'Failed to send email' }, { status: 500 })
}
}Cennik
Pay as you go
| Emails/mo | Cena |
|---|---|
| 10,000 | $15/mo |
| 50,000 | $50/mo |
| 125,000 | $85/mo |
| 300,000 | $175/mo |
| 700,000 | $375/mo |
| 1,500,000 | $725/mo |
| 3,000,000 | $1,225/mo |
Dodatkowe opłaty
- Inbound emails: $0.05 za 10,000
- Dedicated IP: $50/mo per IP
- Templates API: Wliczone w cenę
Free trial
- 100 emaili za darmo do testowania
- Bez karty kredytowej
FAQ - Często Zadawane Pytania
Postmark vs SendGrid - co wybrać?
Postmark to specjalista od transactional z najlepszą deliverability. SendGrid to all-in-one z email marketingiem. Wybierz Postmark gdy deliverability jest priorytetem. Wybierz SendGrid gdy potrzebujesz też email marketingu.
Dlaczego Postmark nie obsługuje email marketingu?
Celowa decyzja - mieszanie transactional i marketing emails na tej samej infrastrukturze obniża deliverability. Postmark utrzymuje czystą reputację IP przez obsługę tylko transactional.
Jak szybko email dotrze do odbiorcy?
Średnio <10 sekund od wysłania do inbox. To jeden z najszybszych wyników w branży.
Co jeśli email wyląduje w spamie?
To rzadkie z Postmark (<1% spam rate), ale jeśli się zdarzy, sprawdź: DKIM/SPF configuration, zawartość emaila (spam triggers), reputację domeny, czy adres jest na suppression list.
Czy mogę wysyłać newslettery przez Postmark?
Tak, ale musisz utworzyć osobny Broadcast stream i przestrzegać zasad opt-in. Postmark nie jest jednak zoptymalizowany pod bulk marketing - rozważ dedykowane narzędzia jak Mailchimp.
Podsumowanie
Postmark to najlepszy wybór dla transactional emails gdy zależy Ci na:
- 99%+ inbox rate - Najlepsza deliverability w branży
- Szybka dostawa - <10 sekund do inbox
- Szczegółowa analityka - Real-time tracking
- Profesjonalne templates - Visual editor + MJML
- Solid webhooks - Bounce, open, click tracking
- Świetny support - Responsywny i pomocny zespół
Jeśli wysyłasz transactional emails i deliverability jest dla Ciebie krytyczna, Postmark to bezpieczny i sprawdzony wybór.
Postmark - a complete guide to the email delivery API
What is Postmark?
Postmark is a specialized email API service created by Wildbit (now part of ActiveCampaign), focused exclusively on transactional emails - automated messages sent in response to user actions: registrations, password resets, order confirmations, notifications, and invoices.
Unlike all-in-one platforms such as SendGrid or Mailgun, Postmark deliberately does not support email marketing. This specialization allows them to achieve the highest deliverability in the industry - over 99% of messages land in the inbox, not in spam.
Postmark stands out through transparency - they publish deliverability statistics in real time, and their infrastructure is optimized for delivery speed (averaging <10 seconds from send to inbox).
Why Postmark?
Key advantages of Postmark
- 99%+ Inbox Rate - Best deliverability in the industry
- Fast delivery - Average <10 seconds to inbox
- Transactional focus - No marketing spam on the infrastructure
- Templates - Visual editor and MJML support
- Message Streams - Separate streams for different email types
- Inbound Email - Receive and parse incoming emails
- Webhooks - Real-time notifications for deliveries, opens, clicks
- Bounce handling - Automatic management of rejected addresses
Postmark vs other email services
| Feature | Postmark | SendGrid | Mailgun | Amazon SES |
|---|---|---|---|---|
| Focus | Transactional only | All-in-one | All-in-one | Infrastructure |
| Deliverability | 99%+ | 95-98% | 95-98% | Varies |
| Delivery time | <10s | 30s-2min | 30s-2min | Varies |
| Price (10K/mo) | $15 | $20 | $15 | $1 |
| Templates | ✅ Visual + MJML | ✅ | ✅ | ❌ |
| Inbound email | ✅ | ✅ | ✅ | ✅ |
| Marketing email | ❌ No | ✅ | ✅ | ✅ |
| Analytics | Detailed | Basic | Detailed | Basic |
| Support | Excellent | Good | Good | Self-service |
When to choose Postmark?
Postmark is ideal when:
- You send transactional emails (auth, notifications, receipts)
- Deliverability is critical (SaaS, e-commerce)
- You need fast delivery (<10s)
- You want professional templates without coding
- You need detailed analytics
Consider alternatives when:
- You need email marketing → SendGrid, Mailchimp
- Budget is minimal → Amazon SES
- You send >1M emails/month → SendGrid, own infrastructure
- You need an all-in-one platform → SendGrid, Brevo
Getting started
Creating an account
- Sign up at postmarkapp.com
- Verify your sender domain
- Add DNS records (DKIM, Return-Path)
- Get your Server API Token from the dashboard
DNS configuration
# DKIM Record
Name: pm._domainkey.yourdomain.com
Type: TXT
Value: k=rsa;p=MIGf... (from the Postmark dashboard)
# Return-Path (bounce handling)
Name: pm-bounces.yourdomain.com
Type: CNAME
Value: pm.mtasv.netSDK installation
# Node.js
npm install postmark
# Python
pip install postmarker
# Ruby
gem install postmark
# PHP
composer require wildbit/postmark-phpClient configuration
// lib/postmark.ts
import { ServerClient } from 'postmark'
// Singleton client
export const postmarkClient = new ServerClient(
process.env.POSTMARK_SERVER_TOKEN!
)
// For multiple servers (different projects)
import { AccountClient } from 'postmark'
export const accountClient = new AccountClient(
process.env.POSTMARK_ACCOUNT_TOKEN!
)# .env.local
POSTMARK_SERVER_TOKEN=your-server-api-token
POSTMARK_ACCOUNT_TOKEN=your-account-api-tokenSending emails
Simple email
import { postmarkClient } from '@/lib/postmark'
// Send a single email
await postmarkClient.sendEmail({
From: 'noreply@yourdomain.com',
To: 'user@example.com',
Subject: 'Witaj w naszej aplikacji!',
TextBody: 'Dziękujemy za rejestrację. Twoje konto jest aktywne.',
HtmlBody: `
<h1>Witaj w naszej aplikacji!</h1>
<p>Dziękujemy za rejestrację. Twoje konto jest aktywne.</p>
<a href="https://app.example.com">Zaloguj się</a>
`,
Tag: 'welcome',
TrackOpens: true,
TrackLinks: 'HtmlAndText',
Metadata: {
userId: '12345',
source: 'registration',
},
})With attachments
import fs from 'fs'
import path from 'path'
await postmarkClient.sendEmail({
From: 'invoices@yourdomain.com',
To: 'customer@example.com',
Subject: 'Twoja faktura #12345',
HtmlBody: '<p>W załączniku faktura.</p>',
Attachments: [
{
Name: 'faktura-12345.pdf',
Content: fs.readFileSync(
path.join(process.cwd(), 'invoices', '12345.pdf')
).toString('base64'),
ContentType: 'application/pdf',
},
{
Name: 'logo.png',
Content: logoBase64,
ContentType: 'image/png',
ContentID: 'cid:logo', // Inline image
},
],
})Reply-To and CC/BCC
await postmarkClient.sendEmail({
From: 'noreply@yourdomain.com',
ReplyTo: 'support@yourdomain.com',
To: 'user@example.com',
Cc: 'manager@example.com',
Bcc: 'archive@yourdomain.com',
Subject: 'Twoje zapytanie',
TextBody: 'Odpowiemy wkrótce.',
})Templates
Postmark offers a template system with a visual editor and variables.
Creating a template in the dashboard
- Servers → Your Server → Templates → New Template
- Choose a type: Basic, Welcome, Password Reset, Receipt, etc.
- Edit in the visual editor or in HTML/MJML code
- Add variables:
{{ variable_name }}
Sending with a template
// Using Template Alias
await postmarkClient.sendEmailWithTemplate({
From: 'noreply@yourdomain.com',
To: 'user@example.com',
TemplateAlias: 'welcome-email', // Alias from the dashboard
TemplateModel: {
name: 'Jan',
product_name: 'MyApp',
action_url: 'https://app.example.com/verify?token=abc123',
support_email: 'support@example.com',
company_name: 'Example Inc.',
company_address: 'ul. Przykładowa 1, Warszawa',
},
Tag: 'welcome',
TrackOpens: true,
})
// Using Template ID
await postmarkClient.sendEmailWithTemplate({
From: 'noreply@yourdomain.com',
To: 'user@example.com',
TemplateId: 12345678,
TemplateModel: {
// ...
},
})Example template (MJML)
<!-- In the Postmark dashboard or locally -->
<mjml>
<mj-head>
<mj-title>{{ subject }}</mj-title>
<mj-attributes>
<mj-all font-family="Arial, sans-serif" />
<mj-text font-size="14px" line-height="1.6" color="#333333" />
</mj-attributes>
</mj-head>
<mj-body background-color="#f4f4f4">
<mj-section background-color="#ffffff" padding="20px">
<mj-column>
<mj-image src="{{ logo_url }}" width="150px" />
</mj-column>
</mj-section>
<mj-section background-color="#ffffff" padding="20px">
<mj-column>
<mj-text font-size="24px" font-weight="bold">
Cześć {{ name }}!
</mj-text>
<mj-text>
{{ message }}
</mj-text>
<mj-button
background-color="#007bff"
href="{{ action_url }}"
font-size="16px"
padding="15px 30px"
>
{{ action_text }}
</mj-button>
</mj-column>
</mj-section>
<mj-section background-color="#f4f4f4" padding="20px">
<mj-column>
<mj-text font-size="12px" color="#666666" align="center">
© {{ current_year }} {{ company_name }}
<br />
{{ company_address }}
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>React Email integration
npm install @react-email/components react-email// emails/WelcomeEmail.tsx
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
} from '@react-email/components'
interface WelcomeEmailProps {
name: string
actionUrl: string
}
export function WelcomeEmail({ name, actionUrl }: WelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Witaj w naszej aplikacji!</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Cześć {name}!</Heading>
<Text style={text}>
Dziękujemy za rejestrację. Kliknij poniższy przycisk, aby aktywować konto.
</Text>
<Section style={buttonContainer}>
<Button style={button} href={actionUrl}>
Aktywuj konto
</Button>
</Section>
</Container>
</Body>
</Html>
)
}
const main = {
backgroundColor: '#f6f9fc',
fontFamily: 'Arial, sans-serif',
}
const container = {
backgroundColor: '#ffffff',
margin: '0 auto',
padding: '40px',
borderRadius: '4px',
}
const h1 = {
color: '#333',
fontSize: '24px',
}
const text = {
color: '#666',
fontSize: '16px',
lineHeight: '24px',
}
const buttonContainer = {
textAlign: 'center' as const,
marginTop: '24px',
}
const button = {
backgroundColor: '#007bff',
color: '#fff',
padding: '12px 24px',
borderRadius: '4px',
textDecoration: 'none',
}
export default WelcomeEmail// lib/send-email.ts
import { render } from '@react-email/render'
import { postmarkClient } from './postmark'
import WelcomeEmail from '@/emails/WelcomeEmail'
export async function sendWelcomeEmail(to: string, name: string, actionUrl: string) {
const html = await render(WelcomeEmail({ name, actionUrl }))
const text = await render(WelcomeEmail({ name, actionUrl }), { plainText: true })
await postmarkClient.sendEmail({
From: 'noreply@yourdomain.com',
To: to,
Subject: 'Witaj w naszej aplikacji!',
HtmlBody: html,
TextBody: text,
Tag: 'welcome',
})
}Batch sending
Sending multiple emails in a single request (up to 500).
// Batch with the same template
const users = [
{ email: 'user1@example.com', name: 'Jan' },
{ email: 'user2@example.com', name: 'Anna' },
{ email: 'user3@example.com', name: 'Piotr' },
]
const messages = users.map((user) => ({
From: 'noreply@yourdomain.com',
To: user.email,
TemplateAlias: 'weekly-digest',
TemplateModel: {
name: user.name,
date: new Date().toLocaleDateString('pl-PL'),
},
}))
const results = await postmarkClient.sendEmailBatchWithTemplates(messages)
// Check results
results.forEach((result, index) => {
if (result.ErrorCode !== 0) {
console.error(`Failed to send to ${users[index].email}:`, result.Message)
}
})// Batch without templates
const notifications = [
{ to: 'user1@example.com', message: 'Notification 1' },
{ to: 'user2@example.com', message: 'Notification 2' },
]
const messages = notifications.map((n) => ({
From: 'noreply@yourdomain.com',
To: n.to,
Subject: 'Nowa notyfikacja',
TextBody: n.message,
}))
await postmarkClient.sendEmailBatch(messages)Message Streams
Message Streams let you separate different email types for better deliverability.
Stream types
- Transactional (default) - auth, notifications, receipts
- Broadcast - newsletters, marketing (requires a separate stream)
- Inbound - receiving emails
Sending to a specific stream
// Transactional (default)
await postmarkClient.sendEmail({
From: 'noreply@yourdomain.com',
To: 'user@example.com',
MessageStream: 'outbound', // default transactional
Subject: 'Reset hasła',
TextBody: 'Kliknij link, aby zresetować hasło.',
})
// Broadcast (newsletter)
await postmarkClient.sendEmail({
From: 'newsletter@yourdomain.com',
To: 'subscriber@example.com',
MessageStream: 'broadcast', // broadcast stream
Subject: 'Weekly Newsletter',
HtmlBody: '<h1>This week...</h1>',
})Creating a new stream
import { AccountClient } from 'postmark'
const accountClient = new AccountClient(process.env.POSTMARK_ACCOUNT_TOKEN!)
await accountClient.createMessageStream({
ID: 'product-updates',
Name: 'Product Updates',
MessageStreamType: 'Broadcasts',
ServerId: 12345,
})Webhooks
Real-time notifications about email events.
Configuring webhooks in the dashboard
Servers → Your Server → Webhooks → Add Webhook
Available events:
- Delivery - Email delivered
- Bounce - Email rejected
- SpamComplaint - Marked as spam
- Open - Email opened
- Click - Link clicked
- SubscriptionChange - Unsubscribe
Webhook handler in Next.js
// app/api/postmark-webhook/route.ts
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'
interface PostmarkWebhookEvent {
RecordType: 'Delivery' | 'Bounce' | 'SpamComplaint' | 'Open' | 'Click' | 'SubscriptionChange'
MessageID: string
Recipient: string
DeliveredAt?: string
BouncedAt?: string
Type?: string
TypeCode?: number
Name?: string
Tag?: string
Description?: string
Details?: string
Email?: string
From?: string
Subject?: string
Metadata?: Record<string, string>
// For Open events
FirstOpen?: boolean
Platform?: string
ReadSeconds?: number
// For Click events
OriginalLink?: string
ClickLocation?: string
}
export async function POST(request: NextRequest) {
const body = await request.text()
// Optional signature verification (if enabled)
const signature = request.headers.get('X-Postmark-Signature')
if (signature) {
const expectedSignature = crypto
.createHmac('sha256', process.env.POSTMARK_WEBHOOK_SECRET!)
.update(body)
.digest('base64')
if (signature !== expectedSignature) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
}
const event: PostmarkWebhookEvent = JSON.parse(body)
try {
switch (event.RecordType) {
case 'Delivery':
console.log(`Email delivered to ${event.Recipient}`)
// Update delivery status in database
await updateEmailStatus(event.MessageID, 'delivered', event.DeliveredAt)
break
case 'Bounce':
console.log(`Bounce from ${event.Recipient}: ${event.Description}`)
// Handle bounce - mark email as invalid
await handleBounce(event.Recipient, event.TypeCode, event.Description)
// Hard bounces (TypeCode 1) - permanently invalid
if (event.TypeCode === 1) {
await markEmailInvalid(event.Recipient)
}
break
case 'SpamComplaint':
console.log(`Spam complaint from ${event.Email}`)
// Immediately unsubscribe user
await unsubscribeUser(event.Email)
await logSpamComplaint(event.Email, event.Subject)
break
case 'Open':
console.log(`Email opened by ${event.Recipient}`)
// Track engagement
await trackEmailOpen(event.MessageID, {
firstOpen: event.FirstOpen,
platform: event.Platform,
readSeconds: event.ReadSeconds,
})
break
case 'Click':
console.log(`Link clicked in email to ${event.Recipient}: ${event.OriginalLink}`)
// Track click
await trackEmailClick(event.MessageID, event.OriginalLink)
break
case 'SubscriptionChange':
console.log(`Subscription change for ${event.Recipient}`)
// Handle unsubscribe
await handleUnsubscribe(event.Recipient, event.Metadata)
break
}
return NextResponse.json({ received: true })
} catch (error) {
console.error('Webhook processing error:', error)
return NextResponse.json({ error: 'Processing failed' }, { status: 500 })
}
}
// Helper functions
async function updateEmailStatus(messageId: string, status: string, timestamp?: string) {
// Update in your database
await prisma.emailLog.update({
where: { messageId },
data: { status, deliveredAt: timestamp },
})
}
async function handleBounce(email: string, typeCode?: number, description?: string) {
await prisma.emailBounce.create({
data: {
email,
bounceType: typeCode === 1 ? 'hard' : 'soft',
description,
bouncedAt: new Date(),
},
})
}
async function markEmailInvalid(email: string) {
await prisma.user.update({
where: { email },
data: { emailValid: false },
})
}
async function unsubscribeUser(email: string) {
await prisma.user.update({
where: { email },
data: { marketingOptIn: false, unsubscribedAt: new Date() },
})
}Bounce handling
Postmark automatically manages bounces and the suppression list.
Bounce types
| Type Code | Name | Description | Action |
|---|---|---|---|
| 1 | HardBounce | Address does not exist | Remove from list |
| 2 | Transient | Temporary problem | Retry later |
| 16 | Unsubscribe | User unsubscribed | Mark as unsubscribed |
| 32 | Subscribe | User re-subscribed | Re-enable notifications |
| 512 | SpamComplaint | Spam report | Remove immediately |
Fetching bounces via the API
// Get recent bounces
const bounces = await postmarkClient.getBounces({
count: 100,
offset: 0,
type: 'HardBounce', // Optional filtering
// inactive: true, // Only inactive
// emailFilter: '@example.com', // Filter by domain
// tag: 'welcome', // Filter by tag
})
bounces.Bounces.forEach((bounce) => {
console.log(`Bounce: ${bounce.Email} - ${bounce.Type} - ${bounce.Description}`)
})
// Reactivate a bounce (if the issue was fixed)
await postmarkClient.activateBounce(bounceId)
// Get a dump of all bounces
const dump = await postmarkClient.getBounceDump(bounceId)Checking before sending
// Check if an email is on the suppression list
async function canSendTo(email: string): Promise<boolean> {
try {
const suppressions = await postmarkClient.getSuppressions('outbound')
return !suppressions.Suppressions.some(
(s) => s.EmailAddress.toLowerCase() === email.toLowerCase()
)
} catch {
return true // On error, allow sending
}
}
// Before sending
if (await canSendTo(userEmail)) {
await postmarkClient.sendEmail({
// ...
})
} else {
console.log(`Email ${userEmail} is suppressed, skipping`)
}Inbound email
Receiving and parsing incoming emails.
Configuration
- Servers → Your Server → Settings → Inbound
- Set up an Inbound domain (e.g.
inbound.yourdomain.com) - Configure the DNS MX record:CodeTEXT
MX 10 inbound.postmarkapp.com - Set the webhook URL for inbound emails
Webhook handler for inbound
// app/api/postmark-inbound/route.ts
import { NextRequest, NextResponse } from 'next/server'
interface PostmarkInboundEmail {
From: string
FromName: string
FromFull: {
Email: string
Name: string
}
To: string
ToFull: Array<{
Email: string
Name: string
}>
Cc: string
CcFull: Array<{
Email: string
Name: string
}>
ReplyTo: string
Subject: string
MessageID: string
Date: string
TextBody: string
HtmlBody: string
StrippedTextReply: string
Tag: string
Headers: Array<{
Name: string
Value: string
}>
Attachments: Array<{
Name: string
Content: string // Base64
ContentType: string
ContentLength: number
ContentID?: string
}>
}
export async function POST(request: NextRequest) {
const email: PostmarkInboundEmail = await request.json()
console.log(`Received email from ${email.From}: ${email.Subject}`)
// Parse email address patterns
const toAddress = email.ToFull[0].Email
// Support ticket pattern: support+ticket123@inbound.yourdomain.com
const ticketMatch = toAddress.match(/support\+ticket(\d+)@/)
if (ticketMatch) {
const ticketId = ticketMatch[1]
await addReplyToTicket(ticketId, {
from: email.From,
subject: email.Subject,
body: email.StrippedTextReply || email.TextBody,
attachments: email.Attachments,
})
return NextResponse.json({ processed: true, action: 'ticket_reply' })
}
// New support request
if (toAddress.startsWith('support@')) {
const ticketId = await createSupportTicket({
from: email.From,
fromName: email.FromName,
subject: email.Subject,
body: email.TextBody,
htmlBody: email.HtmlBody,
attachments: email.Attachments,
})
return NextResponse.json({ processed: true, action: 'ticket_created', ticketId })
}
// Unknown pattern
return NextResponse.json({ processed: false, reason: 'unknown_address' })
}
async function addReplyToTicket(ticketId: string, data: any) {
// Save to database
await prisma.ticketReply.create({
data: {
ticketId,
fromEmail: data.from,
content: data.body,
attachments: {
create: data.attachments.map((a: any) => ({
name: a.Name,
contentType: a.ContentType,
size: a.ContentLength,
data: Buffer.from(a.Content, 'base64'),
})),
},
},
})
// Notify assignee
await notifyTicketAssignee(ticketId)
}
async function createSupportTicket(data: any) {
const ticket = await prisma.supportTicket.create({
data: {
email: data.from,
name: data.fromName,
subject: data.subject,
content: data.body,
status: 'open',
},
})
// Send auto-reply
await postmarkClient.sendEmailWithTemplate({
From: 'support@yourdomain.com',
To: data.from,
TemplateAlias: 'ticket-received',
TemplateModel: {
ticket_id: ticket.id,
subject: data.subject,
},
})
return ticket.id
}Email analytics
Fetching statistics
// Overall statistics
const stats = await postmarkClient.getOutboundOverview({
tag: 'welcome', // Optional
fromDate: '2024-01-01',
toDate: '2024-01-31',
})
console.log(`Sent: ${stats.Sent}`)
console.log(`Bounced: ${stats.Bounced}`)
console.log(`SMTPAPIErrors: ${stats.SMTPAPIErrors}`)
console.log(`BounceRate: ${stats.BounceRate}`)
console.log(`SpamComplaints: ${stats.SpamComplaints}`)
console.log(`SpamComplaintsRate: ${stats.SpamComplaintsRate}`)
console.log(`Opens: ${stats.Opens}`)
console.log(`UniqueOpens: ${stats.UniqueOpens}`)
console.log(`Clicks: ${stats.Clicks}`)
console.log(`UniqueLinksClicked: ${stats.UniqueLinksClicked}`)
// Daily statistics
const dailyStats = await postmarkClient.getOutboundSendCounts({
tag: 'welcome',
fromDate: '2024-01-01',
toDate: '2024-01-31',
})
dailyStats.Days.forEach((day) => {
console.log(`${day.Date}: ${day.Sent} sent`)
})
// Bounce statistics
const bounceStats = await postmarkClient.getOutboundBounceCounts({
fromDate: '2024-01-01',
toDate: '2024-01-31',
})
// Click statistics
const clickStats = await postmarkClient.getOutboundClickCounts({
fromDate: '2024-01-01',
toDate: '2024-01-31',
})
// Platform statistics (devices, email clients)
const platformStats = await postmarkClient.getOutboundOpenPlatforms({
fromDate: '2024-01-01',
toDate: '2024-01-31',
})
platformStats.Days.forEach((day) => {
console.log(`${day.Date}:`)
console.log(` Desktop: ${day.Desktop}`)
console.log(` Mobile: ${day.Mobile}`)
console.log(` WebMail: ${day.WebMail}`)
})Tracking individual messages
// Get details for a specific email
const message = await postmarkClient.getOutboundMessage(messageId)
console.log(`To: ${message.Recipients}`)
console.log(`Subject: ${message.Subject}`)
console.log(`Status: ${message.Status}`)
console.log(`ReceivedAt: ${message.ReceivedAt}`)
// List recent messages
const messages = await postmarkClient.getOutboundMessages({
count: 50,
offset: 0,
recipient: 'user@example.com', // Filter by recipient
tag: 'password-reset', // Filter by tag
status: 'sent', // sent, queued, processed
// fromDate: '2024-01-01',
// toDate: '2024-01-31',
})
messages.Messages.forEach((msg) => {
console.log(`${msg.MessageID}: ${msg.Subject} -> ${msg.Recipients}`)
})Error handling
import { Errors } from 'postmark'
async function sendEmailSafely(to: string, subject: string, body: string) {
try {
const result = await postmarkClient.sendEmail({
From: 'noreply@yourdomain.com',
To: to,
Subject: subject,
TextBody: body,
})
return { success: true, messageId: result.MessageID }
} catch (error) {
if (error instanceof Errors.PostmarkError) {
switch (error.code) {
case 300: // Invalid email request
console.error('Invalid email format:', error.message)
return { success: false, error: 'invalid_email' }
case 406: // Inactive recipient
console.error('Recipient is inactive (bounced/unsubscribed):', to)
return { success: false, error: 'inactive_recipient' }
case 409: // JSON required
case 422: // Invalid JSON
console.error('Invalid request format:', error.message)
return { success: false, error: 'invalid_request' }
case 429: // Rate limit
console.error('Rate limit exceeded, retry later')
return { success: false, error: 'rate_limit', retryAfter: 60 }
case 500: // Postmark server error
case 503: // Service unavailable
console.error('Postmark service error, retry later')
return { success: false, error: 'service_error', retryAfter: 300 }
default:
console.error('Postmark error:', error.code, error.message)
return { success: false, error: 'unknown' }
}
}
throw error
}
}Retry logic
async function sendEmailWithRetry(
emailData: any,
maxRetries = 3,
baseDelay = 1000
) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await postmarkClient.sendEmail(emailData)
} catch (error) {
if (error instanceof Errors.PostmarkError) {
// Don't retry client errors
if (error.code >= 400 && error.code < 500 && error.code !== 429) {
throw error
}
// Last attempt
if (attempt === maxRetries) {
throw error
}
// Exponential backoff
const delay = baseDelay * Math.pow(2, attempt - 1)
console.log(`Retry attempt ${attempt}/${maxRetries} in ${delay}ms`)
await new Promise((resolve) => setTimeout(resolve, delay))
} else {
throw error
}
}
}
}Next.js integration
Server Actions
// app/actions/email.ts
'use server'
import { postmarkClient } from '@/lib/postmark'
import { render } from '@react-email/render'
import WelcomeEmail from '@/emails/WelcomeEmail'
import PasswordResetEmail from '@/emails/PasswordResetEmail'
export async function sendWelcomeEmail(userId: string) {
const user = await prisma.user.findUnique({ where: { id: userId } })
if (!user) throw new Error('User not found')
const verificationUrl = `${process.env.NEXT_PUBLIC_APP_URL}/verify?token=${user.verificationToken}`
const html = await render(WelcomeEmail({
name: user.name,
actionUrl: verificationUrl,
}))
await postmarkClient.sendEmail({
From: 'noreply@yourdomain.com',
To: user.email,
Subject: 'Witaj! Potwierdź swój email',
HtmlBody: html,
Tag: 'welcome',
Metadata: { userId: user.id },
})
}
export async function sendPasswordResetEmail(email: string) {
const user = await prisma.user.findUnique({ where: { email } })
if (!user) return // Don't reveal if user exists
const token = generateSecureToken()
await prisma.passwordReset.create({
data: {
userId: user.id,
token,
expiresAt: new Date(Date.now() + 3600000), // 1 hour
},
})
const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password?token=${token}`
const html = await render(PasswordResetEmail({
name: user.name,
resetUrl,
expiresIn: '1 godzinę',
}))
await postmarkClient.sendEmail({
From: 'noreply@yourdomain.com',
To: email,
Subject: 'Reset hasła',
HtmlBody: html,
Tag: 'password-reset',
Metadata: { userId: user.id },
})
}Route Handlers
// app/api/send-email/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { postmarkClient } from '@/lib/postmark'
import { z } from 'zod'
const sendEmailSchema = z.object({
to: z.string().email(),
subject: z.string().min(1).max(200),
body: z.string().min(1),
template: z.string().optional(),
})
export async function POST(request: NextRequest) {
// Verify authentication
const session = await getServerSession()
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = sendEmailSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues }, { status: 400 })
}
const { to, subject, body: emailBody, template } = parsed.data
try {
if (template) {
await postmarkClient.sendEmailWithTemplate({
From: 'noreply@yourdomain.com',
To: to,
TemplateAlias: template,
TemplateModel: { subject, body: emailBody },
})
} else {
await postmarkClient.sendEmail({
From: 'noreply@yourdomain.com',
To: to,
Subject: subject,
TextBody: emailBody,
})
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Email send error:', error)
return NextResponse.json({ error: 'Failed to send email' }, { status: 500 })
}
}Pricing
Pay as you go
| Emails/mo | Price |
|---|---|
| 10,000 | $15/mo |
| 50,000 | $50/mo |
| 125,000 | $85/mo |
| 300,000 | $175/mo |
| 700,000 | $375/mo |
| 1,500,000 | $725/mo |
| 3,000,000 | $1,225/mo |
Additional charges
- Inbound emails: $0.05 per 10,000
- Dedicated IP: $50/mo per IP
- Templates API: Included in the price
Free trial
- 100 emails free for testing
- No credit card required
FAQ - frequently asked questions
Postmark vs SendGrid - which one to choose?
Postmark is a transactional specialist with the best deliverability. SendGrid is an all-in-one platform with email marketing. Choose Postmark when deliverability is the priority. Choose SendGrid when you also need email marketing.
Why doesn't Postmark support email marketing?
It is a deliberate decision - mixing transactional and marketing emails on the same infrastructure lowers deliverability. Postmark maintains a clean IP reputation by handling only transactional emails.
How fast will an email reach the recipient?
On average <10 seconds from sending to inbox. This is one of the fastest results in the industry.
What if an email lands in spam?
This is rare with Postmark (<1% spam rate), but if it happens, check: DKIM/SPF configuration, email content (spam triggers), domain reputation, and whether the address is on the suppression list.
Can I send newsletters through Postmark?
Yes, but you need to create a separate Broadcast stream and follow opt-in rules. However, Postmark is not optimized for bulk marketing - consider dedicated tools like Mailchimp.
Summary
Postmark is the best choice for transactional emails when you care about:
- 99%+ inbox rate - Best deliverability in the industry
- Fast delivery - <10 seconds to inbox
- Detailed analytics - Real-time tracking
- Professional templates - Visual editor + MJML
- Solid webhooks - Bounce, open, click tracking
- Excellent support - Responsive and helpful team
If you send transactional emails and deliverability is critical for you, Postmark is a safe and proven choice.