We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide36 min read

Postmark

Postmark is an email API with best deliverability for transactional emails. 99%+ inbox rate, templates, webhooks and real-time analytics.

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

  1. 99%+ Inbox Rate - Najlepsza deliverability w branży
  2. Szybka dostawa - Średnio <10 sekund do inbox
  3. Transactional focus - Brak marketingowego spamu na infrastrukturze
  4. Templates - Visual editor i MJML support
  5. Message Streams - Oddzielne strumienie dla różnych typów maili
  6. Inbound Email - Odbieranie i parsowanie przychodzących maili
  7. Webhooks - Real-time notifications o dostarczeniu, otwarciach, kliknięciach
  8. Bounce handling - Automatyczne zarządzanie odrzuconymi adresami

Postmark vs Inne Email Services

CechaPostmarkSendGridMailgunAmazon SES
FokusTransactional onlyAll-in-oneAll-in-oneInfrastructure
Deliverability99%+95-98%95-98%Varies
Czas dostawy<10s30s-2min30s-2minVaries
Cena (10K/mo)$15$20$15$1
Templates✅ Visual + MJML
Inbound email
Marketing email❌ Nie
AnalyticsSzczegółowePodstawoweSzczegółowePodstawowe
SupportŚwietnyDobryDobrySelf-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

  1. Zarejestruj się na postmarkapp.com
  2. Zweryfikuj domenę nadawcy
  3. Dodaj rekordy DNS (DKIM, Return-Path)
  4. Pobierz Server API Token z dashboardu

DNS Configuration

Code
TEXT
# 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.net

Instalacja SDK

JSNode.js
JavaScript
# Node.js
npm install postmark

# Python
pip install postmarker

# Ruby
gem install postmark

# PHP
composer require wildbit/postmark-php

Konfiguracja klienta

TSlib/postmark.ts
TypeScript
// 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
ENV
# .env.local
POSTMARK_SERVER_TOKEN=your-server-api-token
POSTMARK_ACCOUNT_TOKEN=your-account-api-token

Wysyłanie Emaili

Prosty email

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

  1. Servers → Your Server → Templates → New Template
  2. Wybierz typ: Basic, Welcome, Password Reset, Receipt, itd.
  3. Edytuj w visual editorze lub kodzie HTML/MJML
  4. Dodaj zmienne: {{ variable_name }}

Wysyłanie z template

Code
TypeScript
// 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)

Code
HTML
<!-- 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

Code
Bash
npm install @react-email/components react-email
TSemails/WelcomeEmail.tsx
TypeScript
// 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
TSlib/send-email.ts
TypeScript
// 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).

Code
TypeScript
// 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)
  }
})
Code
TypeScript
// 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

Code
TypeScript
// 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

Code
TypeScript
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

TSapp/api/postmark-webhook/route.ts
TypeScript
// 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 CodeNazwaOpisAkcja
1HardBounceAdres nie istniejeUsuń z listy
2TransientTymczasowy problemRetry później
16UnsubscribeUser wypisał sięOznacz unsubscribed
32SubscribeUser zapisał się ponownieWłącz powiadomienia
512SpamComplaintSpam reportNatychmiast usuń

Pobieranie bounce'ów przez API

Code
TypeScript
// 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ą

Code
TypeScript
// 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

  1. Servers → Your Server → Settings → Inbound
  2. Ustaw Inbound domain (np. inbound.yourdomain.com)
  3. Skonfiguruj DNS MX record:
    Code
    TEXT
    MX 10 inbound.postmarkapp.com
  4. Ustaw webhook URL dla inbound emails

Webhook handler dla inbound

TSapp/api/postmark-inbound/route.ts
TypeScript
// 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

Code
TypeScript
// 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

Code
TypeScript
// 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

Code
TypeScript
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

Code
TypeScript
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

TSapp/actions/email.ts
TypeScript
// 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

TSapp/api/send-email/route.ts
TypeScript
// 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/moCena
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

  1. 99%+ Inbox Rate - Best deliverability in the industry
  2. Fast delivery - Average <10 seconds to inbox
  3. Transactional focus - No marketing spam on the infrastructure
  4. Templates - Visual editor and MJML support
  5. Message Streams - Separate streams for different email types
  6. Inbound Email - Receive and parse incoming emails
  7. Webhooks - Real-time notifications for deliveries, opens, clicks
  8. Bounce handling - Automatic management of rejected addresses

Postmark vs other email services

FeaturePostmarkSendGridMailgunAmazon SES
FocusTransactional onlyAll-in-oneAll-in-oneInfrastructure
Deliverability99%+95-98%95-98%Varies
Delivery time<10s30s-2min30s-2minVaries
Price (10K/mo)$15$20$15$1
Templates✅ Visual + MJML
Inbound email
Marketing email❌ No
AnalyticsDetailedBasicDetailedBasic
SupportExcellentGoodGoodSelf-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

  1. Sign up at postmarkapp.com
  2. Verify your sender domain
  3. Add DNS records (DKIM, Return-Path)
  4. Get your Server API Token from the dashboard

DNS configuration

Code
TEXT
# 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.net

SDK installation

JSNode.js
JavaScript
# Node.js
npm install postmark

# Python
pip install postmarker

# Ruby
gem install postmark

# PHP
composer require wildbit/postmark-php

Client configuration

TSlib/postmark.ts
TypeScript
// 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
ENV
# .env.local
POSTMARK_SERVER_TOKEN=your-server-api-token
POSTMARK_ACCOUNT_TOKEN=your-account-api-token

Sending emails

Simple email

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

  1. Servers → Your Server → Templates → New Template
  2. Choose a type: Basic, Welcome, Password Reset, Receipt, etc.
  3. Edit in the visual editor or in HTML/MJML code
  4. Add variables: {{ variable_name }}

Sending with a template

Code
TypeScript
// 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)

Code
HTML
<!-- 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

Code
Bash
npm install @react-email/components react-email
TSemails/WelcomeEmail.tsx
TypeScript
// 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
TSlib/send-email.ts
TypeScript
// 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).

Code
TypeScript
// 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)
  }
})
Code
TypeScript
// 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

Code
TypeScript
// 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

Code
TypeScript
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

TSapp/api/postmark-webhook/route.ts
TypeScript
// 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 CodeNameDescriptionAction
1HardBounceAddress does not existRemove from list
2TransientTemporary problemRetry later
16UnsubscribeUser unsubscribedMark as unsubscribed
32SubscribeUser re-subscribedRe-enable notifications
512SpamComplaintSpam reportRemove immediately

Fetching bounces via the API

Code
TypeScript
// 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

Code
TypeScript
// 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

  1. Servers → Your Server → Settings → Inbound
  2. Set up an Inbound domain (e.g. inbound.yourdomain.com)
  3. Configure the DNS MX record:
    Code
    TEXT
    MX 10 inbound.postmarkapp.com
  4. Set the webhook URL for inbound emails

Webhook handler for inbound

TSapp/api/postmark-inbound/route.ts
TypeScript
// 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

Code
TypeScript
// 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

Code
TypeScript
// 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

Code
TypeScript
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

Code
TypeScript
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

TSapp/actions/email.ts
TypeScript
// 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

TSapp/api/send-email/route.ts
TypeScript
// 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/moPrice
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.