Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds
Powrót do kolekcji
Przewodnik24 min czytania

Resend, API do wysyłania maili transakcyjnych

Resend to API do wysyłania maili z szablonami pisanymi w Reakcie. Cennik, dostarczalność, konfiguracja domeny i porównanie z Postmarkiem.

Resend, API do wysyłania maili transakcyjnych

Resend to usługa do wysyłania wiadomości z aplikacji: potwierdzeń rejestracji, resetów hasła, powiadomień o zamówieniu. Odróżnia ją od konkurencji to, że szablony pisze się jako komponenty Reacta, a nie jako HTML z tabelkami. Bibliotekę kliencką wydano na licencji MIT, w chwili pisania w wersji 6.18.1.

Co dokładnie kupujesz

Warto rozdzielić dwie rzeczy, które nowicjusze mylą, a które kosztują różne pieniądze i rozwiązują różne problemy.

Pierwsza to samo wysłanie wiadomości. Technicznie da się to zrobić z własnego serwera w kilkanaście linii kodu i nie potrzeba do tego żadnej usługi. Druga to doprowadzenie tej wiadomości do skrzynki odbiorczej zamiast do folderu ze spamem, a to jest problem zupełnie innej klasy.

Za tę drugą część się płaci. Usługa utrzymuje pulę adresów o dobrej reputacji, pilnuje, żeby nie trafiły na listy blokujące, obsługuje pętle zwrotne od dużych dostawców poczty i odsiewa adresy, które odbijają. Serwer pocztowy postawiony samodzielnie na maszynie w chmurze startuje z adresem o zerowej historii, a często z adresem z zakresu, który duzi dostawcy traktują z góry podejrzliwie.

Wniosek praktyczny jest prosty: samodzielne wysyłanie ma sens przy wiadomościach wewnętrznych, gdzie odbiorca i tak dopisze nadawcę do zaufanych. Przy czymkolwiek, co idzie do klienta, koszt usługi jest niższy niż koszt jednego dnia dochodzenia, dlaczego resety hasła nie docierają.

Szablony w Reakcie, główna cecha wyróżniająca

HTML w wiadomościach pocztowych to osobna dziedzina i jedna z nielicznych, w których nadal obowiązują reguły z lat dziewięćdziesiątych. Układ buduje się tabelami, style pisze wewnątrz znaczników, a każdy program pocztowy interpretuje to nieco inaczej. Ręczne pisanie takiego kodu jest nieprzyjemne, a utrzymanie go przy zmianie identyfikacji wizualnej jeszcze gorsze.

Biblioteka React Email, tworzona przez ten sam zespół, pozwala napisać wiadomość jako komponent, a potem wygenerować z niego HTML zgodny z tymi wszystkimi ograniczeniami. Piszesz normalnie, w składni, którą znasz z aplikacji, a warstwa kompilująca zajmuje się tabelami i stylami wewnętrznymi.

Zysk jest największy tam, gdzie wiadomości jest dużo i mają wspólne elementy. Nagłówek, stopka, przycisk i układ kolorów wydzielasz raz i używasz w dwudziestu szablonach, dokładnie tak jak w Reakcie po stronie aplikacji. Zmiana logotypu przestaje być przeszukiwaniem dwudziestu plików HTML.

Trzeba jednak wiedzieć, że to nie znosi ograniczeń samego medium. Nadal nie ma tam skryptów, nadal część programów pocztowych ignoruje część reguł stylów, nadal trzeba testować na kilku klientach. Biblioteka ułatwia pisanie, nie zmienia zasad gry.

Cennik

Rozliczenie idzie na dwóch osiach i to jest pierwsza rzecz, która zaskakuje przy planowaniu budżetu. Wiadomości transakcyjne płaci się od liczby wysyłek, a wiadomości marketingowe od liczby kontaktów na liście.

Plan transakcyjnyCena miesięcznieWiadomościDomeny
Free0 USD3 0001
Prood 20 USD50 00010
Pro35 USD100 00010
Scaleod 90 USD100 0001 000
Scale350 USD500 0001 000
Scale650 USD1 000 0001 000

Ścieżka marketingowa jest osobna i tam liczy się rozmiar bazy kontaktów, nie liczba wysyłek. Plan darmowy obejmuje tysiąc kontaktów, płatny zaczyna się od 40 dolarów miesięcznie za pięć tysięcy i rośnie do 650 dolarów przy stu pięćdziesięciu tysiącach.

Dwie rzeczy warto zauważyć przy porównywaniu planów. Limit domen w planie darmowym wynosi jeden, co przy kilku projektach wyklucza go szybciej niż limit wiadomości. Retencja logów to trzydzieści dni na wszystkich planach, więc jeśli potrzebujesz dłuższej historii dla celów rozliczeniowych, musisz ją zapisywać po swojej stronie.

Do tego dochodzą dwie pozycje, których w tabeli nie widać, a które potrafią rozstrzygnąć o wyborze. Plan darmowy ma osobny limit dzienny, sto wiadomości, więc trzy tysiące miesięcznie nie oznacza, że da się je wysłać w jeden dzień. Przy powiadomieniach wysyłanych falami, na przykład po nocnym przetwarzaniu wsadowym, ten limit uderza wcześniej niż miesięczny.

Druga to naliczanie za nadwyżkę. Powyżej wolumenu zawartego w planie płacisz od 46 do 90 centów za tysiąc wiadomości, zależnie od poziomu. Przy ruchu skaczącym z miesiąca na miesiąc warto policzyć, czy taniej wyjdzie nadwyżka na niższym planie, czy przejście na wyższy z zapasem.

Instalacja i konfiguracja

Instalacja pakietów

Code
Bash
# Główny pakiet Resend
npm install resend

# React Email dla szablonów (opcjonalnie, ale zalecane)
npm install @react-email/components

# Dla podglądu szablonów lokalnie
npm install react-email --save-dev

Konfiguracja API Key

Utwórz konto na resend.com i wygeneruj API key:

TSlib/resend.ts
TypeScript
// lib/resend.ts
import { Resend } from 'resend'

if (!process.env.RESEND_API_KEY) {
  throw new Error('Missing RESEND_API_KEY environment variable')
}

export const resend = new Resend(process.env.RESEND_API_KEY)
.env.local
Bash
# .env.local
RESEND_API_KEY=re_xxxxxxxxxxxxx

Podstawowe wysyłanie emaili

Prosty email tekstowy

Code
TypeScript
import { resend } from '@/lib/resend'

const { data, error } = await resend.emails.send({
  from: 'hello@yourdomain.com',
  to: 'user@example.com',
  subject: 'Witaj w naszej aplikacji!',
  text: 'Dziękujemy za rejestrację. Twoje konto jest już aktywne.',
})

if (error) {
  console.error('Failed to send email:', error)
  return
}

console.log('Email sent:', data.id)

Email z HTML

Code
TypeScript
const { data, error } = await resend.emails.send({
  from: 'notifications@yourdomain.com',
  to: ['user1@example.com', 'user2@example.com'],
  subject: 'Nowe zamówienie #12345',
  html: `
    <h1>Potwierdzenie zamówienia</h1>
    <p>Dziękujemy za złożenie zamówienia!</p>
    <p>Numer zamówienia: <strong>#12345</strong></p>
    <a href="https://example.com/orders/12345">Zobacz szczegóły</a>
  `,
})

Opcje zaawansowane

Code
TypeScript
const { data, error } = await resend.emails.send({
  from: 'Jan Kowalski <jan@yourdomain.com>',
  to: 'user@example.com',
  cc: ['manager@company.com'],
  bcc: ['archive@company.com'],
  reply_to: 'support@yourdomain.com',
  subject: 'Ważna wiadomość',
  html: '<p>Treść wiadomości</p>',

  // Załączniki
  attachments: [
    {
      filename: 'raport.pdf',
      content: pdfBuffer, // Buffer lub base64 string
    },
  ],

  // Tagi do śledzenia
  tags: [
    { name: 'category', value: 'order_confirmation' },
    { name: 'user_id', value: '12345' },
  ],

  // Planowane wysłanie
  scheduled_at: '2024-12-25T09:00:00Z',

  // Nagłówki
  headers: {
    'X-Entity-Ref-ID': 'order-12345',
  },
})

React Email - Szablony jako komponenty

Tworzenie szablonu

React Email pozwala tworzyć szablony emaili jako komponenty React:

TSemails/welcome.tsx
TypeScript
// emails/welcome.tsx
import {
  Html,
  Head,
  Body,
  Container,
  Section,
  Text,
  Button,
  Img,
  Link,
  Preview,
  Hr,
} from '@react-email/components'

interface WelcomeEmailProps {
  username: string
  verificationUrl: string
}

export default function WelcomeEmail({
  username,
  verificationUrl,
}: WelcomeEmailProps) {
  return (
    <Html>
      <Head />
      <Preview>Witaj w CodeWorlds, {username}!</Preview>
      <Body style={main}>
        <Container style={container}>
          <Img
            src="https://yourdomain.com/logo.png"
            width={150}
            height={50}
            alt="CodeWorlds"
          />

          <Section style={section}>
            <Text style={heading}>Witaj, {username}!</Text>
            <Text style={text}>
              Dziękujemy za dołączenie do CodeWorlds. Twoja przygoda z
              programowaniem właśnie się rozpoczyna!
            </Text>

            <Button style={button} href={verificationUrl}>
              Aktywuj konto
            </Button>

            <Text style={text}>
              Lub skopiuj ten link do przeglądarki:
            </Text>
            <Link href={verificationUrl} style={link}>
              {verificationUrl}
            </Link>
          </Section>

          <Hr style={hr} />

          <Section style={footer}>
            <Text style={footerText}>
              © 2024 CodeWorlds. Wszystkie prawa zastrzeżone.
            </Text>
            <Link href="https://yourdomain.com/unsubscribe" style={footerLink}>
              Wypisz się z newslettera
            </Link>
          </Section>
        </Container>
      </Body>
    </Html>
  )
}

// Style inline (wymagane dla emaili)
const main = {
  backgroundColor: '#f6f9fc',
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}

const container = {
  backgroundColor: '#ffffff',
  margin: '0 auto',
  padding: '20px 0 48px',
  marginBottom: '64px',
}

const section = {
  padding: '0 48px',
}

const heading = {
  fontSize: '24px',
  fontWeight: 'bold',
  marginBottom: '16px',
}

const text = {
  fontSize: '16px',
  lineHeight: '26px',
  color: '#333',
}

const button = {
  backgroundColor: '#5046e5',
  borderRadius: '6px',
  color: '#fff',
  fontSize: '16px',
  fontWeight: 'bold',
  textDecoration: 'none',
  textAlign: 'center' as const,
  display: 'block',
  padding: '12px 24px',
  margin: '24px 0',
}

const link = {
  color: '#5046e5',
  textDecoration: 'underline',
  wordBreak: 'break-all' as const,
}

const hr = {
  borderColor: '#e6ebf1',
  margin: '32px 0',
}

const footer = {
  padding: '0 48px',
}

const footerText = {
  fontSize: '12px',
  color: '#8898aa',
}

const footerLink = {
  fontSize: '12px',
  color: '#8898aa',
}

Wysyłanie z React Email

Code
TypeScript
import { resend } from '@/lib/resend'
import WelcomeEmail from '@/emails/welcome'

export async function sendWelcomeEmail(
  email: string,
  username: string,
  verificationToken: string
) {
  const verificationUrl = `https://yourdomain.com/verify?token=${verificationToken}`

  const { data, error } = await resend.emails.send({
    from: 'CodeWorlds <welcome@yourdomain.com>',
    to: email,
    subject: `Witaj w CodeWorlds, ${username}!`,
    react: WelcomeEmail({ username, verificationUrl }),
  })

  if (error) {
    throw new Error(`Failed to send welcome email: ${error.message}`)
  }

  return data
}

Podgląd szablonów lokalnie

package.json
JSON
// package.json
{
  "scripts": {
    "email:dev": "email dev --dir emails --port 3001"
  }
}
Code
Bash
npm run email:dev
# Otwórz http://localhost:3001 dla podglądu szablonów

Biblioteka szablonów emaili

Email potwierdzenia zamówienia

TSemails/order-confirmation.tsx
TypeScript
// emails/order-confirmation.tsx
import {
  Html,
  Head,
  Body,
  Container,
  Section,
  Row,
  Column,
  Text,
  Button,
  Img,
  Hr,
} from '@react-email/components'

interface OrderItem {
  name: string
  quantity: number
  price: number
  imageUrl: string
}

interface OrderConfirmationProps {
  orderNumber: string
  customerName: string
  items: OrderItem[]
  subtotal: number
  shipping: number
  total: number
  shippingAddress: string
  trackingUrl: string
}

export default function OrderConfirmation({
  orderNumber,
  customerName,
  items,
  subtotal,
  shipping,
  total,
  shippingAddress,
  trackingUrl,
}: OrderConfirmationProps) {
  return (
    <Html>
      <Head />
      <Body style={main}>
        <Container style={container}>
          <Section style={header}>
            <Text style={heading}>Potwierdzenie zamówienia</Text>
            <Text style={orderNum}>Zamówienie #{orderNumber}</Text>
          </Section>

          <Section style={section}>
            <Text style={greeting}>Cześć {customerName}!</Text>
            <Text style={text}>
              Dziękujemy za zamówienie. Oto podsumowanie:
            </Text>
          </Section>

          <Section style={itemsSection}>
            {items.map((item, index) => (
              <Row key={index} style={itemRow}>
                <Column style={imageColumn}>
                  <Img
                    src={item.imageUrl}
                    width={64}
                    height={64}
                    alt={item.name}
                    style={itemImage}
                  />
                </Column>
                <Column style={detailsColumn}>
                  <Text style={itemName}>{item.name}</Text>
                  <Text style={itemQuantity}>Ilość: {item.quantity}</Text>
                </Column>
                <Column style={priceColumn}>
                  <Text style={itemPrice}>{item.price.toFixed(2)}</Text>
                </Column>
              </Row>
            ))}
          </Section>

          <Hr style={hr} />

          <Section style={summarySection}>
            <Row>
              <Column><Text style={summaryLabel}>Produkty:</Text></Column>
              <Column><Text style={summaryValue}>{subtotal.toFixed(2)}</Text></Column>
            </Row>
            <Row>
              <Column><Text style={summaryLabel}>Dostawa:</Text></Column>
              <Column><Text style={summaryValue}>{shipping.toFixed(2)}</Text></Column>
            </Row>
            <Row>
              <Column><Text style={totalLabel}>Razem:</Text></Column>
              <Column><Text style={totalValue}>{total.toFixed(2)}</Text></Column>
            </Row>
          </Section>

          <Section style={section}>
            <Text style={addressLabel}>Adres dostawy:</Text>
            <Text style={address}>{shippingAddress}</Text>
          </Section>

          <Section style={ctaSection}>
            <Button style={button} href={trackingUrl}>
              Śledź przesyłkę
            </Button>
          </Section>
        </Container>
      </Body>
    </Html>
  )
}

const main = { backgroundColor: '#f6f9fc', fontFamily: 'Arial, sans-serif' }
const container = { backgroundColor: '#ffffff', margin: '0 auto', padding: '40px' }
const header = { textAlign: 'center' as const, marginBottom: '32px' }
const heading = { fontSize: '28px', fontWeight: 'bold', color: '#1a1a1a' }
const orderNum = { fontSize: '14px', color: '#666' }
const greeting = { fontSize: '18px', fontWeight: '600' }
const text = { fontSize: '16px', color: '#333', lineHeight: '24px' }
const section = { marginBottom: '24px' }
const itemsSection = { backgroundColor: '#f9fafb', padding: '16px', borderRadius: '8px' }
const itemRow = { marginBottom: '16px' }
const imageColumn = { width: '80px' }
const detailsColumn = { paddingLeft: '16px' }
const priceColumn = { textAlign: 'right' as const }
const itemImage = { borderRadius: '8px' }
const itemName = { fontSize: '14px', fontWeight: '600', margin: '0' }
const itemQuantity = { fontSize: '12px', color: '#666', margin: '4px 0 0' }
const itemPrice = { fontSize: '14px', fontWeight: '600' }
const hr = { borderColor: '#e6ebf1', margin: '24px 0' }
const summarySection = { marginBottom: '24px' }
const summaryLabel = { fontSize: '14px', color: '#666' }
const summaryValue = { fontSize: '14px', textAlign: 'right' as const }
const totalLabel = { fontSize: '16px', fontWeight: 'bold' }
const totalValue = { fontSize: '16px', fontWeight: 'bold', textAlign: 'right' as const }
const addressLabel = { fontSize: '14px', fontWeight: '600', marginBottom: '8px' }
const address = { fontSize: '14px', color: '#666', whiteSpace: 'pre-line' as const }
const ctaSection = { textAlign: 'center' as const }
const button = {
  backgroundColor: '#000',
  color: '#fff',
  padding: '12px 32px',
  borderRadius: '6px',
  fontSize: '14px',
  fontWeight: 'bold',
  textDecoration: 'none',
}

Email resetowania hasła

TSemails/password-reset.tsx
TypeScript
// emails/password-reset.tsx
import {
  Html,
  Head,
  Body,
  Container,
  Section,
  Text,
  Button,
  Link,
} from '@react-email/components'

interface PasswordResetProps {
  resetUrl: string
  expiresIn: string
  ipAddress: string
  userAgent: string
}

export default function PasswordReset({
  resetUrl,
  expiresIn,
  ipAddress,
  userAgent,
}: PasswordResetProps) {
  return (
    <Html>
      <Head />
      <Body style={main}>
        <Container style={container}>
          <Section style={section}>
            <Text style={heading}>Reset hasła</Text>
            <Text style={text}>
              Otrzymaliśmy prośbę o zresetowanie hasła do Twojego konta.
              Kliknij poniższy przycisk, aby ustawić nowe hasło.
            </Text>

            <Button style={button} href={resetUrl}>
              Zresetuj hasło
            </Button>

            <Text style={text}>
              Link jest ważny przez {expiresIn}. Jeśli nie prosiłeś o reset
              hasła, zignoruj tę wiadomość.
            </Text>

            <Section style={securitySection}>
              <Text style={securityHeading}>Szczegóły żądania:</Text>
              <Text style={securityText}>IP: {ipAddress}</Text>
              <Text style={securityText}>Przeglądarka: {userAgent}</Text>
            </Section>

            <Text style={footerText}>
              Jeśli nie rozpoznajesz tej aktywności,{' '}
              <Link href="https://yourdomain.com/security" style={link}>
                zabezpiecz swoje konto
              </Link>
              .
            </Text>
          </Section>
        </Container>
      </Body>
    </Html>
  )
}

const main = { backgroundColor: '#f6f9fc', fontFamily: 'Arial, sans-serif' }
const container = { backgroundColor: '#ffffff', margin: '0 auto', padding: '40px' }
const section = { padding: '0' }
const heading = { fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }
const text = { fontSize: '16px', lineHeight: '26px', color: '#333' }
const button = {
  backgroundColor: '#dc2626',
  color: '#fff',
  padding: '14px 32px',
  borderRadius: '6px',
  fontSize: '16px',
  fontWeight: 'bold',
  textDecoration: 'none',
  display: 'block',
  textAlign: 'center' as const,
  margin: '24px 0',
}
const securitySection = {
  backgroundColor: '#fef2f2',
  padding: '16px',
  borderRadius: '8px',
  marginTop: '24px',
}
const securityHeading = { fontSize: '14px', fontWeight: '600', margin: '0 0 8px' }
const securityText = { fontSize: '12px', color: '#666', margin: '4px 0' }
const footerText = { fontSize: '14px', color: '#666', marginTop: '24px' }
const link = { color: '#dc2626' }

Integracja z Next.js

API Route (App Router)

TSapp/api/send-email/route.ts
TypeScript
// app/api/send-email/route.ts
import { NextResponse } from 'next/server'
import { resend } from '@/lib/resend'
import WelcomeEmail from '@/emails/welcome'

export async function POST(request: Request) {
  try {
    const { email, username, verificationToken } = await request.json()

    if (!email || !username) {
      return NextResponse.json(
        { error: 'Email and username are required' },
        { status: 400 }
      )
    }

    const verificationUrl = `${process.env.NEXT_PUBLIC_APP_URL}/verify?token=${verificationToken}`

    const { data, error } = await resend.emails.send({
      from: 'CodeWorlds <noreply@yourdomain.com>',
      to: email,
      subject: `Witaj w CodeWorlds, ${username}!`,
      react: WelcomeEmail({ username, verificationUrl }),
    })

    if (error) {
      console.error('Resend error:', error)
      return NextResponse.json(
        { error: 'Failed to send email' },
        { status: 500 }
      )
    }

    return NextResponse.json({ id: data.id })
  } catch (error) {
    console.error('Server error:', error)
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

Server Action

TSapp/actions/email.ts
TypeScript
// app/actions/email.ts
'use server'

import { resend } from '@/lib/resend'
import ContactEmail from '@/emails/contact'

interface ContactFormData {
  name: string
  email: string
  subject: string
  message: string
}

export async function sendContactEmail(formData: ContactFormData) {
  try {
    const { data, error } = await resend.emails.send({
      from: 'Contact Form <contact@yourdomain.com>',
      to: 'team@yourdomain.com',
      reply_to: formData.email,
      subject: `[Kontakt] ${formData.subject}`,
      react: ContactEmail({
        name: formData.name,
        email: formData.email,
        message: formData.message,
      }),
    })

    if (error) {
      return { success: false, error: error.message }
    }

    return { success: true, id: data.id }
  } catch (error) {
    return { success: false, error: 'Failed to send email' }
  }
}

Formularz kontaktowy

TScomponents/ContactForm.tsx
TypeScript
// components/ContactForm.tsx
'use client'

import { useState } from 'react'
import { sendContactEmail } from '@/app/actions/email'

export function ContactForm() {
  const [isSubmitting, setIsSubmitting] = useState(false)
  const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    setIsSubmitting(true)
    setMessage(null)

    const formData = new FormData(e.currentTarget)

    const result = await sendContactEmail({
      name: formData.get('name') as string,
      email: formData.get('email') as string,
      subject: formData.get('subject') as string,
      message: formData.get('message') as string,
    })

    setIsSubmitting(false)

    if (result.success) {
      setMessage({ type: 'success', text: 'Wiadomość wysłana!' })
      e.currentTarget.reset()
    } else {
      setMessage({ type: 'error', text: result.error || 'Wystąpił błąd' })
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label htmlFor="name" className="block text-sm font-medium">
          Imię i nazwisko
        </label>
        <input
          type="text"
          id="name"
          name="name"
          required
          className="mt-1 block w-full rounded-md border px-3 py-2"
        />
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium">
          Email
        </label>
        <input
          type="email"
          id="email"
          name="email"
          required
          className="mt-1 block w-full rounded-md border px-3 py-2"
        />
      </div>

      <div>
        <label htmlFor="subject" className="block text-sm font-medium">
          Temat
        </label>
        <input
          type="text"
          id="subject"
          name="subject"
          required
          className="mt-1 block w-full rounded-md border px-3 py-2"
        />
      </div>

      <div>
        <label htmlFor="message" className="block text-sm font-medium">
          Wiadomość
        </label>
        <textarea
          id="message"
          name="message"
          rows={4}
          required
          className="mt-1 block w-full rounded-md border px-3 py-2"
        />
      </div>

      {message && (
        <div className={`p-3 rounded ${
          message.type === 'success' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
        }`}>
          {message.text}
        </div>
      )}

      <button
        type="submit"
        disabled={isSubmitting}
        className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
      >
        {isSubmitting ? 'Wysyłanie...' : 'Wyślij wiadomość'}
      </button>
    </form>
  )
}

Webhooks - Śledzenie statusu emaili

Konfiguracja webhooków

Resend może wysyłać webhooks przy różnych wydarzeniach:

  • email.sent - Email został wysłany
  • email.delivered - Email został dostarczony
  • email.opened - Email został otwarty
  • email.clicked - Link w emailu został kliknięty
  • email.bounced - Email odbił się
  • email.complained - Użytkownik oznaczył email jako spam

Endpoint webhooks

TSapp/api/webhooks/resend/route.ts
TypeScript
// app/api/webhooks/resend/route.ts
import { NextResponse } from 'next/server'
import { headers } from 'next/headers'
import crypto from 'crypto'

const RESEND_WEBHOOK_SECRET = process.env.RESEND_WEBHOOK_SECRET!

interface ResendWebhookPayload {
  type: string
  created_at: string
  data: {
    email_id: string
    from: string
    to: string[]
    subject: string
    created_at: string
    tags?: { name: string; value: string }[]
  }
}

function verifySignature(payload: string, signature: string): boolean {
  const expectedSignature = crypto
    .createHmac('sha256', RESEND_WEBHOOK_SECRET)
    .update(payload)
    .digest('hex')

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  )
}

export async function POST(request: Request) {
  try {
    const headersList = headers()
    const signature = headersList.get('resend-signature')

    if (!signature) {
      return NextResponse.json(
        { error: 'Missing signature' },
        { status: 401 }
      )
    }

    const body = await request.text()

    if (!verifySignature(body, signature)) {
      return NextResponse.json(
        { error: 'Invalid signature' },
        { status: 401 }
      )
    }

    const payload: ResendWebhookPayload = JSON.parse(body)

    // Przetwarzanie różnych typów wydarzeń
    switch (payload.type) {
      case 'email.sent':
        console.log('Email sent:', payload.data.email_id)
        await handleEmailSent(payload.data)
        break

      case 'email.delivered':
        console.log('Email delivered:', payload.data.email_id)
        await handleEmailDelivered(payload.data)
        break

      case 'email.opened':
        console.log('Email opened:', payload.data.email_id)
        await handleEmailOpened(payload.data)
        break

      case 'email.clicked':
        console.log('Email clicked:', payload.data.email_id)
        await handleEmailClicked(payload.data)
        break

      case 'email.bounced':
        console.log('Email bounced:', payload.data.email_id)
        await handleEmailBounced(payload.data)
        break

      case 'email.complained':
        console.log('Email marked as spam:', payload.data.email_id)
        await handleEmailComplained(payload.data)
        break

      default:
        console.log('Unknown event type:', payload.type)
    }

    return NextResponse.json({ received: true })
  } catch (error) {
    console.error('Webhook error:', error)
    return NextResponse.json(
      { error: 'Webhook processing failed' },
      { status: 500 }
    )
  }
}

// Handlers
async function handleEmailSent(data: ResendWebhookPayload['data']) {
  // Zapisz status w bazie danych
}

async function handleEmailDelivered(data: ResendWebhookPayload['data']) {
  // Aktualizuj status w bazie
}

async function handleEmailOpened(data: ResendWebhookPayload['data']) {
  // Zapisz otwarcie dla analityki
}

async function handleEmailClicked(data: ResendWebhookPayload['data']) {
  // Śledź kliknięcia linków
}

async function handleEmailBounced(data: ResendWebhookPayload['data']) {
  // Oznacz email jako nieaktywny
  // Usuń z listy mailingowej
}

async function handleEmailComplained(data: ResendWebhookPayload['data']) {
  // Natychmiast usuń z wszystkich list
  // Zapisz w blacklist
}

Zarządzanie domenami

Weryfikacja domeny

Code
TypeScript
import { resend } from '@/lib/resend'

// Dodanie domeny
const { data: domain, error } = await resend.domains.create({
  name: 'yourdomain.com',
  region: 'eu-west-1', // lub 'us-east-1'
})

// Lista domen
const { data: domains } = await resend.domains.list()

// Weryfikacja domeny
const { data: verified } = await resend.domains.verify('domain_id')

// Szczegóły domeny (rekordy DNS)
const { data: domainDetails } = await resend.domains.get('domain_id')
console.log(domainDetails.records) // Rekordy DNS do dodania

Rekordy DNS

Po dodaniu domeny, Resend zwróci rekordy DNS do skonfigurowania:

Code
TypeScript
// Przykładowa odpowiedź
{
  records: [
    {
      type: 'MX',
      name: 'send',
      value: 'feedback-smtp.eu-west-1.amazonses.com',
      priority: 10
    },
    {
      type: 'TXT',
      name: 'send',
      value: 'v=spf1 include:amazonses.com ~all'
    },
    {
      type: 'TXT',
      name: 'resend._domainkey',
      value: 'p=MIGfMA0GCSqGSIb3DQEBAQUAA...'
    }
  ]
}

Audience i kontakty

Zarządzanie listami odbiorców

Code
TypeScript
import { resend } from '@/lib/resend'

// Tworzenie audience (listy)
const { data: audience } = await resend.audiences.create({
  name: 'Newsletter Subscribers'
})

// Dodawanie kontaktu
const { data: contact } = await resend.contacts.create({
  audience_id: audience.id,
  email: 'user@example.com',
  first_name: 'Jan',
  last_name: 'Kowalski',
  unsubscribed: false,
})

// Pobieranie kontaktów
const { data: contacts } = await resend.contacts.list({
  audience_id: audience.id,
})

// Aktualizacja kontaktu
await resend.contacts.update({
  audience_id: audience.id,
  id: contact.id,
  first_name: 'John',
})

// Usunięcie kontaktu
await resend.contacts.remove({
  audience_id: audience.id,
  id: contact.id,
})

Wysyłka do audience

Code
TypeScript
const { data, error } = await resend.emails.send({
  from: 'Newsletter <newsletter@yourdomain.com>',
  to: audience.id, // ID audience zamiast konkretnego emaila
  subject: 'Nowy artykuł na blogu',
  react: NewsletterEmail({ title: 'Artykuł', content: '...' }),
})

Batch sending

Wysyłanie wielu emaili naraz

Code
TypeScript
import { resend } from '@/lib/resend'

const emails = [
  {
    from: 'newsletter@yourdomain.com',
    to: 'user1@example.com',
    subject: 'Newsletter #1',
    html: '<p>Content 1</p>',
  },
  {
    from: 'newsletter@yourdomain.com',
    to: 'user2@example.com',
    subject: 'Newsletter #1',
    html: '<p>Content 2</p>',
  },
  // ... więcej emaili
]

const { data, error } = await resend.batch.send(emails)

// data zawiera array z ID każdego emaila
console.log(data) // [{ id: 'email_1' }, { id: 'email_2' }]

Personalizowane batch sending

Code
TypeScript
interface Subscriber {
  email: string
  name: string
  preferences: string[]
}

async function sendPersonalizedNewsletter(subscribers: Subscriber[]) {
  const emails = subscribers.map(subscriber => ({
    from: 'newsletter@yourdomain.com',
    to: subscriber.email,
    subject: `Cześć ${subscriber.name}! Nowy newsletter`,
    react: NewsletterEmail({
      name: subscriber.name,
      topics: subscriber.preferences,
    }),
    tags: [
      { name: 'campaign', value: 'weekly-newsletter' },
      { name: 'subscriber_id', value: subscriber.email },
    ],
  }))

  // Batch po 100 emaili (limit Resend)
  const batches = []
  for (let i = 0; i < emails.length; i += 100) {
    batches.push(emails.slice(i, i + 100))
  }

  const results = []
  for (const batch of batches) {
    const { data, error } = await resend.batch.send(batch)
    if (error) {
      console.error('Batch error:', error)
    }
    results.push(...(data || []))

    // Czekaj między batchami
    await new Promise(resolve => setTimeout(resolve, 1000))
  }

  return results
}

Rate limiting i obsługa błędów

Implementacja rate limiting

Code
TypeScript
import { resend } from '@/lib/resend'

class EmailService {
  private queue: Array<() => Promise<void>> = []
  private processing = false
  private rateLimit = 10 // emails per second

  async send(params: Parameters<typeof resend.emails.send>[0]) {
    return new Promise((resolve, reject) => {
      this.queue.push(async () => {
        try {
          const result = await this.sendWithRetry(params)
          resolve(result)
        } catch (error) {
          reject(error)
        }
      })

      this.processQueue()
    })
  }

  private async processQueue() {
    if (this.processing) return
    this.processing = true

    while (this.queue.length > 0) {
      const batch = this.queue.splice(0, this.rateLimit)
      await Promise.all(batch.map(fn => fn()))
      await new Promise(resolve => setTimeout(resolve, 1000))
    }

    this.processing = false
  }

  private async sendWithRetry(
    params: Parameters<typeof resend.emails.send>[0],
    retries = 3
  ) {
    for (let i = 0; i < retries; i++) {
      const { data, error } = await resend.emails.send(params)

      if (!error) {
        return data
      }

      // Retry na rate limit lub server error
      if (error.statusCode === 429 || error.statusCode >= 500) {
        const delay = Math.pow(2, i) * 1000 // Exponential backoff
        await new Promise(resolve => setTimeout(resolve, delay))
        continue
      }

      // Nie retry na inne błędy
      throw error
    }

    throw new Error('Max retries exceeded')
  }
}

export const emailService = new EmailService()

Dobre praktyki

1. Używaj tagów do śledzenia

Code
TypeScript
await resend.emails.send({
  // ...
  tags: [
    { name: 'type', value: 'transactional' },
    { name: 'campaign', value: 'welcome-series' },
    { name: 'user_id', value: userId },
  ],
})

2. Zawsze waliduj adresy email

Code
TypeScript
import { z } from 'zod'

const emailSchema = z.string().email()

async function sendEmail(to: string, subject: string, content: string) {
  const validatedEmail = emailSchema.parse(to)

  await resend.emails.send({
    from: 'noreply@yourdomain.com',
    to: validatedEmail,
    subject,
    html: content,
  })
}

3. Obsługuj unsubscribe

Code
TypeScript
// W każdym marketingowym emailu
<Text style={footerText}>
  Nie chcesz otrzymywać tych wiadomości?{' '}
  <Link href={`https://yourdomain.com/unsubscribe?email=${encodeURIComponent(email)}`}>
    Wypisz się
  </Link>
</Text>

4. Testuj szablony przed wysyłką

Code
TypeScript
// Użyj onboarding@resend.dev do testów
const { data, error } = await resend.emails.send({
  from: 'onboarding@resend.dev', // Testowa domena Resend
  to: 'test@example.com',
  subject: 'Test email',
  react: TestEmail(),
})

Cennik i limity

PlanEmaile/miesiącCenaLimity
Free3,000$0100/dzień
Pro50,000$20/mo+ $0.28/1000 over
Scale100,000$90/mo+ $0.25/1000 over
EnterpriseCustomCustomDedykowana infrastruktura

Rate limits

  • Free: 2 emails/second
  • Pro: 10 emails/second
  • Scale: 50 emails/second
  • Batch API: max 100 emails per request

FAQ - Często zadawane pytania

Czy Resend jest lepszy od SendGrid?

Resend oferuje lepsze developer experience dzięki React Email i nowoczesnemu API. SendGrid ma więcej funkcji marketingowych, ale Resend jest prostszy do integracji w aplikacjach Next.js.

Jak wysyłać emaile z załącznikami?

Użyj pola attachments z Buffer lub base64:

Code
TypeScript
const { data } = await resend.emails.send({
  // ...
  attachments: [
    {
      filename: 'report.pdf',
      content: Buffer.from(pdfData),
    },
  ],
})

Czy mogę używać własnej domeny od razu?

Nie od razu, bo domenę trzeba zweryfikować wpisami w DNS. Do pierwszych testów służy domena testowa udostępniana przez usługę, ale nie nadaje się do produkcji.

Jak śledzić otwarcia wiadomości?

Włącz śledzenie w panelu i obsłuż odpowiednie zdarzenie po stronie swojego punktu końcowego. Traktuj te dane orientacyjnie, bo część programów pocztowych blokuje mechanizm, na którym śledzenie się opiera.

Dostarczalność, czyli za co naprawdę płacisz

To najważniejsza sekcja tego tekstu, bo dotyczy jedynego problemu, którego samo API nie rozwiąże za Ciebie. Usługa daje dobrą infrastrukturę wysyłkową, ale reputacja Twojej domeny jest Twoja i to Ty ją budujesz albo psujesz.

Konfiguracja DNS to warunek wstępny, nie opcja. Potrzebujesz trzech rzeczy: wpisu SPF mówiącego, kto może wysyłać w imieniu Twojej domeny, podpisu DKIM potwierdzającego, że wiadomość nie została zmieniona po drodze, oraz polityki DMARC określającej, co zrobić z wiadomością, która tych warunków nie spełnia. Bez kompletu duzi dostawcy poczty coraz częściej odrzucają przesyłki, a nie tylko oznaczają je jako podejrzane.

Druga sprawa to rozdzielenie ruchu. Wiadomości transakcyjne i marketingowe warto wysyłać z osobnych poddomen, na przykład jednej dla powiadomień systemowych i drugiej dla newslettera. Powód jest praktyczny: kampania marketingowa, która zbierze dużo zgłoszeń jako spam, potrafi zepsuć reputację całej domeny i pociągnąć za sobą resety hasła, które muszą dochodzić zawsze.

Trzecia to higiena listy odbiorców. Adres, który odbija trwale, trzeba usunąć i nigdy więcej na niego nie pisać. Wysyłanie na martwe adresy jest jednym z najsilniejszych sygnałów, po których dostawcy poczty rozpoznają nadawcę niedbałego albo kupującego bazy.

Czwarta, najczęściej pomijana, to treść. Wiadomość zbudowana wyłącznie z jednego dużego obrazka, z adresem odsyłacza innym niż widoczny tekst albo bez wersji tekstowej wygląda podejrzanie dla filtrów, niezależnie od tego, jak dobrze skonfigurowałeś DNS.

Resend a alternatywy

CechaResendPostmarkAmazon SESSendGrid
Szablony jako komponentytak, React Emailnienienie
Próg wejścianiskiniskiwysokiśredni
Koszt przy dużym wolumenieśredniwysokinajniższyśredni
Rozdzielenie ruchu transakcyjnegopoddomenyosobne strumienie wbudowaneręczneręczne
Retencja logów30 dni45 dnizależna od konfiguracjizależna od planu
Plan darmowy3 000 wiadomościograniczony okres próbnyzależny od użyciaograniczony

Wybór sprowadza się zwykle do trzech scenariuszy. Przy aplikacji na Next.js i wiadomościach liczonych w tysiącach miesięcznie Resend wygrywa wygodą i tym, że szablony pisze się w tym samym języku co resztę aplikacji. Przy setkach tysięcy wiadomości miesięcznie Amazon SES jest wielokrotnie tańszy i różnica przestaje być zaniedbywalna, płacisz za nią jednak konfiguracją i brakiem wygodnego panelu. Postmark warto rozważyć, gdy najbardziej zależy Ci na rozdzieleniu strumieni i szczegółowej diagnostyce dostarczeń.

Kolejkowanie i to, co dzieje się przy awarii

Wysyłka wiadomości jest wywołaniem sieciowym do usługi zewnętrznej, więc może się nie udać, i pytanie brzmi, co wtedy. Odpowiedź „spróbujemy jeszcze raz" jest niewystarczająca, bo trzeba jeszcze wiedzieć, kiedy i ile razy.

Rozsądny układ wygląda tak, że aplikacja nie wysyła wiadomości bezpośrednio, tylko zapisuje zamiar wysłania i oddaje sterowanie użytkownikowi. Osobny proces podejmuje ten zamiar, wykonuje wywołanie i oznacza wynik. Dzięki temu chwilowa niedostępność usługi nie zamienia się w utracone potwierdzenie zamówienia, a użytkownik nie czeka na coś, na co i tak nie ma wpływu.

Przy ponawianiu obowiązuje jedna zasada, której złamanie boli podwójnie. Ponawiaj tylko wtedy, gdy nie masz pewności, czy wiadomość poszła, i zabezpiecz się kluczem idempotencji, żeby dwukrotne wykonanie nie skończyło się dwiema identycznymi wiadomościami u klienta. Podwójny reset hasła wygląda niewinnie, podwójna faktura już nie.

Warto też rozróżnić błędy, które ma sens ponawiać, od tych, które nie mają. Przekroczenie limitu żądań albo błąd po stronie serwera usługi to sytuacja przejściowa. Odrzucenie adresu jako nieprawidłowego albo odmowa wysyłki z niezweryfikowanej domeny nie poprawią się przy dziesiątej próbie i powinny trafić do obsługi błędów, a nie do kolejki.

Typowe błędy

Pierwszy to trzymanie klucza po stronie przeglądarki. Klucz do wysyłki ma pełne uprawnienia, więc wywołanie z komponentu klienckiego ujawnia go każdemu odwiedzającemu. Wysyłkę uruchamiaj wyłącznie z serwera, w Next.js z trasy API albo akcji serwerowej.

Drugi to brak zabezpieczenia formularza kontaktowego. Punkt końcowy wysyłający wiadomość bez ograniczenia liczby żądań zostanie znaleziony i wykorzystany do rozsyłania spamu z Twojej domeny, co kończy się utratą reputacji w kilka godzin.

Trzeci to wysyłanie synchroniczne w trakcie obsługi żądania użytkownika. Jeśli usługa odpowiada wolniej niż zwykle, użytkownik czeka na potwierdzenie rejestracji zamiast dostać odpowiedź natychmiast. Wysyłkę warto zdjąć z drogi krytycznej i wykonać poza cyklem żądania.

Czwarty to ignorowanie zdarzeń zwrotnych. Informacja o trwałym odbiciu albo o zgłoszeniu spamu przychodzi na Twój punkt końcowy i musi skutkować oznaczeniem adresu w bazie. Bez tego wysyłasz w kółko na adresy, które psują Ci reputację.

Piąty to testowanie wyłącznie na własnej skrzynce. Wiadomość, która świetnie wygląda w jednym programie pocztowym, potrafi się rozjechać w innym, a filtr antyspamowy w firmowej poczcie działa inaczej niż w skrzynce prywatnej. Sprawdź na co najmniej trzech różnych odbiorcach przed uruchomieniem.

Dokumentację API opisuje strona Resend, a bibliotekę szablonów znajdziesz w projekcie React Email.