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 transakcyjny | Cena miesięcznie | Wiadomości | Domeny |
|---|---|---|---|
| Free | 0 USD | 3 000 | 1 |
| Pro | od 20 USD | 50 000 | 10 |
| Pro | 35 USD | 100 000 | 10 |
| Scale | od 90 USD | 100 000 | 1 000 |
| Scale | 350 USD | 500 000 | 1 000 |
| Scale | 650 USD | 1 000 000 | 1 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
# 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-devKonfiguracja API Key
Utwórz konto na resend.com i wygeneruj API key:
// 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
RESEND_API_KEY=re_xxxxxxxxxxxxxPodstawowe wysyłanie emaili
Prosty email tekstowy
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
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
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:
// 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
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
{
"scripts": {
"email:dev": "email dev --dir emails --port 3001"
}
}npm run email:dev
# Otwórz http://localhost:3001 dla podglądu szablonówBiblioteka szablonów emaili
Email potwierdzenia zamówienia
// 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)} zł</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)} zł</Text></Column>
</Row>
<Row>
<Column><Text style={summaryLabel}>Dostawa:</Text></Column>
<Column><Text style={summaryValue}>{shipping.toFixed(2)} zł</Text></Column>
</Row>
<Row>
<Column><Text style={totalLabel}>Razem:</Text></Column>
<Column><Text style={totalValue}>{total.toFixed(2)} zł</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
// 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)
// 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
// 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
// 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łanyemail.delivered- Email został dostarczonyemail.opened- Email został otwartyemail.clicked- Link w emailu został klikniętyemail.bounced- Email odbił sięemail.complained- Użytkownik oznaczył email jako spam
Endpoint webhooks
// 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
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 dodaniaRekordy DNS
Po dodaniu domeny, Resend zwróci rekordy DNS do skonfigurowania:
// 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
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
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
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
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
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
await resend.emails.send({
// ...
tags: [
{ name: 'type', value: 'transactional' },
{ name: 'campaign', value: 'welcome-series' },
{ name: 'user_id', value: userId },
],
})2. Zawsze waliduj adresy email
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
// 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ą
// 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
| Plan | Emaile/miesiąc | Cena | Limity |
|---|---|---|---|
| Free | 3,000 | $0 | 100/dzień |
| Pro | 50,000 | $20/mo | + $0.28/1000 over |
| Scale | 100,000 | $90/mo | + $0.25/1000 over |
| Enterprise | Custom | Custom | Dedykowana 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:
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
| Cecha | Resend | Postmark | Amazon SES | SendGrid |
|---|---|---|---|---|
| Szablony jako komponenty | tak, React Email | nie | nie | nie |
| Próg wejścia | niski | niski | wysoki | średni |
| Koszt przy dużym wolumenie | średni | wysoki | najniższy | średni |
| Rozdzielenie ruchu transakcyjnego | poddomeny | osobne strumienie wbudowane | ręczne | ręczne |
| Retencja logów | 30 dni | 45 dni | zależna od konfiguracji | zależna od planu |
| Plan darmowy | 3 000 wiadomości | ograniczony okres próbny | zależny od użycia | ograniczony |
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.