Blazalek.com

09 / 10

Niezawodność wysyłki

Wysyłka jako problem systemów rozproszonych: idempotencja (zero duplikatów), ponawianie z backoffem, obsługa kodów błędów i kolejkowanie odseparowane od ścieżki żądania.

Autor:

Najważniejsze

  • Traktuj wysyłkę jak problem systemów rozproszonych: rób wysyłki idempotentne, żeby ponowienia nigdy nie duplikowały maili.
  • Ponawiaj z backoffem i obsługuj kody błędów dostawcy osobno, ponawiaj przejściowe 4xx, nie ponawiaj trwałych 5xx.
  • Odseparuj wysyłkę od ścieżki żądania kolejką, żeby wolny dostawca nigdy nie blokował aplikacji.
Na tej stronie

Wysłanie maila wygląda jak pojedyncze wywołanie funkcji, ale pod spodem jest to problem z dziedziny systemów rozproszonych. Pomiędzy Twoją aplikacją a dostawcą poczty znajduje się zawodna sieć, a każda ze stron może ulec awarii, spowolnić działanie lub stracić pamięć o tym, co się wydarzyło. Ten rozdział odpowiada na pytanie, jak sprawić, by ten niepewny świat zachowywał się przewidywalnie: każdy mail, który powinien zostać wysłany, jest wysyłany dokładnie raz, awarie przejściowe są naprawiane automatycznie, a awarie trwałe są wychwytywane, logowane i zgłaszane człowiekowi, zanim wyrządzą szkodę.

Cztery filary to idempotencja (nigdy nie wysyłaj duplikatu), logika ponawiania (recover po awariach przejściowych), obsługa błędów (poprawna reakcja na każdą klasę awarii) oraz kolejkowanie (przetrwanie crashy i odsprzężenie wysyłki od ścieżki żądania). Timeouty spinają to wszystko razem. Przykłady używają generycznego wrappera emailClient, ale każdy wzorzec jest niezależny od dostawcy. Te same zasady dotyczą Amazon SES, Postmark, SendGrid, Mailgun, Resend, Brevo, SparkPost, Mandrill albo Twojego własnego relaya SMTP.

Ten rozdział dotyczy mechaniki niezawodnego przesyłania bajtów z Twojej aplikacji do dostawcy. Jest to operacyjny odpowiednik dwóch sąsiednich tematów: Maile transakcyjne opisują, co te maile powinny zawierać i kiedy je wysyłać, a Webhooki i zdarzenia opisują, co dzieje się po tym, jak dostawca zaakceptuje wiadomość (dostarczenie, bounce, skarga). Wysyłka zaakceptowana przez dostawcę to nie to samo co wiadomość dostarczona do skrzynki odbiorczej; ten rozdział kończy się na akceptacji przez dostawcę, a rozdział o webhookach przejmuje pałeczkę od tego momentu.

Szybkie odpowiedzi:

  • Po co e-mailowi w ogóle inżynieria niezawodności? Bo zduplikowany, zgubiony lub zapętlony w ponowieniach mail kosztuje zaufanie, zgłoszenia do supportu i reputację nadawcy.
  • Jak przestać wysyłać duplikaty? Użyj klucza idempotencji wyprowadzonego ze stabilnego zdarzenia biznesowego.
  • Które błędy należy ponawiać? 5xx, 429, timeouty, resety połączeń i awarie DNS. Nigdy zwykłego 4xx.
  • Jak długo czekać między ponowieniami? Wykładniczy backoff (1s, 2s, 4s, 8s) ograniczony do 30s, z jitterem.
  • Kiedy potrzebuję kolejki? Dla wszystkiego, na co użytkownik czeka, lub wszystkiego, co jest wymagane prawnie.
  • Jaki timeout ustawić? Od 10 do 30 sekund na wywołanie API.
  • Jaka jest jedna zasada, która spina to wszystko razem? Najgorsza awaria to ta cicha, każda ścieżka kodu musi logować, kolejkować lub alarmować.

Po co e-mailowi w ogóle inżynieria niezawodności?

Bo tryby awarii nie są kosmetyczne, kosztują zaufanie, obciążenie supportu i dostarczalność. Niezawodność nie jest miłym dodatkiem w przypadku poczty transakcyjnej. To różnica między systemem, któremu użytkownicy ufają, a takim, któremu nie ufają.

Trzy konkretne przykłady tego, co idzie nie tak bez niej:

  • Zduplikowany mail „Twoje hasło zostało zresetowane" niszczy zaufanie i może być odczytany przez odbiorcę jako sygnał związany z bezpieczeństwem.
  • Cicho zgubione potwierdzenie zamówienia generuje zgłoszenie do supportu i zdezorientowanego klienta.
  • Burza ponowień podczas awarii może doprowadzić do throttlingu Twojego IP wysyłkowego, co następnie szkodzi każdemu innemu mailowi, który wysyłasz.

Reszta tego rozdziału to zestaw narzędzi, który zapobiega każdej z tych sytuacji.

Dlaczego „żądanie zwróciło 200" to nie to samo co „mail dotarł"?

Wysyłka maila ma co najmniej cztery odrębne granice sukcesu, a ich mylenie jest źródłem większości błędów niezawodności. Traktuj je jako osobne stany:

GranicaCo oznaczaSkąd o niej wieszAwaria obsługiwana w
Żądanie zaakceptowaneAPI dostawcy zwróciło 2xx i identyfikator wiadomościSynchroniczna odpowiedź APITen rozdział
Zakolejkowane przez dostawcęDostawca trwale zaakceptował wiadomość do dostarczeniaTa sama odpowiedź 2xxTen rozdział
Dostarczone do MXOdbierający serwer poczty zaakceptował wiadomość (SMTP 250)Webhook deliveredWebhooki i zdarzenia
Umieszczone w skrzynce odbiorczejWiadomość trafiła do inboxu, a nie do spamu/kwarantannyWnioskowane, nigdy nie potwierdzane przez SMTPDostarczalność

Zadaniem tego rozdziału jest uczynienie pierwszych dwóch granic niezawodnymi: każda wiadomość, która powinna zostać zaakceptowana przez dostawcę, jest akceptowana dokładnie raz. Późniejsze granice są poza Twoją synchroniczną kontrolą i są obserwowane asynchronicznie przez webhooki. Projektowanie tak, jakby 200 oznaczało „użytkownik dostał maila", wprowadzi w błąd każde dochodzenie powypadkowe, które kiedykolwiek przeprowadzisz.

Co tak naprawdę oznacza „przy skali" dla wskaźników awarii?

Awarie, które przy niskim wolumenie wydają się dziwnymi zdarzeniami, przy dużym wolumenie są rutyną. Jeśli pojedyncza wysyłka ma 99,9% wskaźnik sukcesu (0,1% wskaźnik awarii przejściowych, co jest optymistyczne dla dowolnego wywołania przez sieć), to przy 1 000 000 wysyłek dziennie zobaczysz mniej więcej 1000 awarii przejściowych każdego dnia. Bez automatycznego odzyskiwania to 1000 zgubionych maili dziennie. Cały sens tego rozdziału polega na przekształceniu tych 0,1% z „zgubionych wiadomości" w „automatycznie odzyskane wiadomości, niewidoczne dla użytkownika". Inżynieria niezawodności to nie paranoja; to arytmetyka.

Jak mam się upewnić, że nigdy nie wyślę tego samego maila dwa razy?

Użyj klucza idempotencji. Idempotencja oznacza, że operacja może być wykonana wiele razy i da ten sam wynik co wykonanie jej raz. W przypadku maila idempotentna wysyłka gwarantuje, że ponowienie żądania, z jakiegokolwiek powodu, nigdy nie wytworzy drugiego maila w skrzynce odbiorcy.

Co dokładnie idzie nie tak bez idempotencji?

Klasyczna niejednoznaczna awaria. Problemy z siecią, timeouty lub błędy serwera mogą sprawić, że nie będziesz pewien, czy mail faktycznie został wysłany:

  1. Twoja aplikacja wywołuje API poczty.
  2. Dostawca odbiera żądanie, wysyła mail i zaczyna zapisywać odpowiedź.
  3. Połączenie sieciowe zrywa się, zanim odpowiedź do Ciebie dotrze.
  4. Twoja aplikacja widzi timeout i zakłada, że wysyłka się nie powiodła.

Z Twojej strony jest to nie do odróżnienia od żądania, które nigdy nie dotarło. Jeśli ponowisz naiwnie, wyślesz maila dwa razy. Odbiorca dostaje dwie identyczne wiadomości, a w przypadku maila o wysokiej stawce, takiego jak potwierdzenie płatności lub jednorazowy kod logowania, jest to zarówno mylące, jak i potencjalny sygnał związany z bezpieczeństwem.

Niewygodna prawda: nigdy nie możesz być w 100% pewien, że żądanie się powiodło, polegając wyłącznie na stronie klienta. To jest problem dwóch generałów zastosowany do poczty, żadna liczba potwierdzeń nie może dać obu stronom pewności w obecności kanału podatnego na straty. Każdy system, który ponawia (a niezawodne systemy muszą ponawiać), potrzebuje sposobu na uczynienie ponowień bezpiecznymi. Tym mechanizmem jest idempotencja. Idempotencja nie eliminuje niepewności; czyni niepewność nieszkodliwą, przenosząc decyzję o deduplikacji do jedynego miejsca, które ma autorytatywną wiedzę, do dostawcy.

Jak działa klucz idempotencji?

Ponowienie po timeoutcie
Podwójny klik użytkownika
Redostawa z kolejki (at-least-once)
Klucz idempotencji

pwd-reset:{eventId}

Widziany już wcześniej? → pomiń wysyłkę.

Dokładnie 1 mail wysłany2× duplikat → odrzucony
Ten sam wyzwalacz może odpalić wiele razy. Klucz idempotencji sprawia, że powstaje dokładnie jeden mail.

Wysyłasz unikalny klucz z każdym żądaniem. Serwer zapisuje klucz wraz z wynikiem pierwszego żądania. Jeśli ten sam klucz przyjdzie ponownie, serwer zwraca oryginalną odpowiedź zamiast wykonywać akcję po raz drugi.

Kod dla zespołu technicznego (TypeScript)
// Generate deterministic key based on the business event
const idempotencyKey = `password-reset-${userId}-${resetRequestId}`;

await emailClient.send({
  from: 'noreply@example.com',
  to: user.email,
  subject: 'Reset your password',
  html: emailHtml,
}, {
  headers: {
    'Idempotency-Key': idempotencyKey
  }
});

Kluczowy wniosek jest taki, że to serwer wymusza unikalność. Twoja logika ponawiania może być brutalnie prosta, po prostu wyślij to samo żądanie z tym samym kluczem ponownie, a poprawność jest gwarantowana po stronie dostawcy. Jest to znacznie bardziej niezawodne niż próba śledzenia „czy to się powiodło?" we własnej bazie danych, ponieważ zamyka dokładnie ten wyścig opisany powyżej (odpowiedź zgubiona w tranzycie).

Co dzieje się po stronie serwera, gdy klucz się powtarza?

Zrozumienie maszyny stanów dostawcy mówi Ci dokładnie, jakie gwarancje dostajesz, a jakich nie. Poprawny idempotentny endpoint zachowuje się tak:

  1. Pierwsze żądanie z kluczem K przychodzi. Dostawca atomowo rezerwuje K (zwykle INSERT ... ON CONFLICT DO NOTHING lub SETNX w magazynie klucz-wartość), następnie wykonuje wysyłkę i zapisuje treść odpowiedzi oraz status pod K.
  2. Powtórzenie K, gdy pierwsze żądanie jest jeszcze w locie, zwraca albo 409 Conflict („żądanie z tym kluczem jest w trakcie realizacji"), albo blokuje aż do zakończenia pierwszego. Twoja logika ponawiania musi traktować 409 jako „oryginał wygrywa, wycofaj się i sprawdź ponownie", a nie jako twardą awarię.
  3. Powtórzenie K po zakończeniu pierwszego zwraca zapisaną odpowiedź, ten sam kod statusu, ten sam identyfikator wiadomości, bez ponownej wysyłki.

Wynikają z tego dwie konsekwencje. Po pierwsze, klucz musi zostać zarezerwowany przed efektem ubocznym, w przeciwnym razie dwa współbieżne żądania mogą oba przejść kontrolę i oba wysłać. Po drugie, deduplikacja jest ograniczona do ładunku żądania tylko w takim zakresie, w jakim dostawca to wybierze; niektórzy dostawcy odrzucają powtórzony klucz, którego treść się różni (422), inni cicho ignorują nową treść i odtwarzają stary wynik. Nigdy nie polegaj na zmianie treści pod ponownie użytym kluczem, wygeneruj nowy klucz, gdy treść zmienia się w sposób uzasadniony.

Jak powinienem generować klucz?

Wartość idempotencji zależy całkowicie od tego, jak generujesz klucz. Dobry klucz jest stabilny przy każdym ponowieniu tej samej logicznej wysyłki i różny dla każdej odrębnej wysyłki.

StrategiaPrzykładStosuj, gdy
Oparta na zdarzeniuorder-confirm-${orderId}Jeden mail na zdarzenie (zalecane)
Ograniczona do żądaniareset-${userId}-${resetRequestId}Ponowienia w obrębie tego samego żądania
Złożona${tenantId}:${template}:${eventId}Multi-tenant, wiele szablonów
UUIDcrypto.randomUUID()Brak naturalnego klucza (wygeneruj raz, użyj ponownie przy ponowieniu)

Jak wybrać:

  • Klucze oparte na zdarzeniu to złoty standard. Jeśli Twoja reguła biznesowa brzmi „dokładnie jedno potwierdzenie zamówienia na zamówienie", to order-confirm-${orderId} koduje tę regułę bezpośrednio w kluczu. Nawet jeśli dwie osobne ścieżki kodu lub dwa współbieżne workery spróbują wysłać potwierdzenie, dostawca je zdeduplikuje. To czyni klucz znaczącym i samodokumentującym.
  • Klucze ograniczone do żądania pasują do przypadków, w których ten sam użytkownik może w sposób uzasadniony wywołać wiele wysyłek tego samego typu, ale każde indywidualne żądanie powinno wytworzyć jeden mail. Reset hasła to kanoniczny przykład: użytkownik może zażądać kilku resetów, a każdy dostaje własne resetRequestId, więc każdy jest odrębną wysyłką, ale ponowienie w obrębie jednego żądania używa ponownie tego samego klucza.
  • Klucze złożone mają znaczenie w systemach multi-tenant. Nadanie przestrzeni nazw kluczowi za pomocą identyfikatora tenanta zapobiega kolizji, w której dwóch tenantów przypadkiem dzieli orderId z niezależnych liczników. Gołe order-confirm-1001 jest niebezpieczne pomiędzy tenantami; tenant-42:order-confirm-1001 jest bezpieczne.
  • UUID-y są rozwiązaniem awaryjnym, gdy nie istnieje żaden naturalny identyfikator. Krytyczna zasada: wygeneruj UUID raz, zapisz go i użyj ponownie przy każdym ponowieniu. UUID wygenerowany od nowa przy każdej próbie nie zapewnia żadnej ochrony. Nie jest lepszy niż brak klucza w ogóle.

Najlepsza praktyka: używaj deterministycznych kluczy opartych na zdarzeniu biznesowym. Jeśli ponawiasz tę samą logiczną wysyłkę, ten sam klucz musi zostać wygenerowany. Unikaj Date.now() lub losowych wartości generowanych od nowa przy każdej próbie, ponieważ obie zmieniają się między ponowieniami i niweczą cały mechanizm. Dobry test myślowy: „Jeśli mój proces ulegnie awarii i zrestartuje się w trakcie wysyłki, czy mój kod wygeneruje na nowo identyczny klucz?" Jeśli odpowiedź brzmi „nie", Twój klucz jest błędny.

Przykład praktyczny: wyprowadzanie stabilnego klucza ze zdarzenia

Gdy nie masz oczywistego pojedynczego identyfikatora, zahaszuj stabilne części zdarzenia za pomocą standardowego crypto z Node, bez zewnętrznej zależności, w pełni deterministycznie:

Kod dla zespołu technicznego (TypeScript)
import { createHash } from 'node:crypto';

// Deterministic key from the immutable facts of the send.
// Same inputs => same key, on every retry and after any crash.
function idempotencyKeyFor(event: {
  type: string;        // e.g. "order.confirmation"
  tenantId: string;
  recipient: string;
  resourceId: string;  // e.g. orderId, invoiceId
}): string {
  const canonical = [
    event.type,
    event.tenantId,
    event.recipient.trim().toLowerCase(),
    event.resourceId,
  ].join('|');

  // 256-bit hash, hex-encoded. Stable, collision-resistant, opaque.
  return createHash('sha256').update(canonical).digest('hex');
}

Normalizacja odbiorcy (trim().toLowerCase()) jest celowa: chociaż lokalna część adresu SMTP jest technicznie wrażliwa na wielkość liter (RFC 5321 §2.4), w praktyce każdy duży dostawca skrzynek pocztowych traktuje User@Example.com i user@example.com jako tę samą skrzynkę, a nie chcesz, żeby różnica w wielkości liter wyglądała jak odrębna wysyłka. Część domenowa jest z definicji niewrażliwa na wielkość liter, więc zamiana na małe litery jest zawsze bezpieczna.

Jaki jest częsty błąd idempotencji, którego należy unikać?

Traktowanie klucza jako tokena na próbę, a nie tokena na wysyłkę. Najczęstsze błędy:

  • Generowanie świeżej losowej wartości lub znacznika czasu wewnątrz pętli ponawiania, tak że każda próba niesie inny klucz. Dostawca widzi każdą próbę jako zupełnie nową wysyłkę, a deduplikacja nic nie robi.
  • Brak utrwalenia klucza wraz z zakolejkowanym zadaniem. Wiadomość, która siedzi w kolejce martwych listów (dead-letter queue) i jest odtwarzana później, traci ochronę, jeśli klucz był trzymany tylko w pamięci.
  • Włączenie ulotnych danych do klucza. Jeśli Twój klucz zawiera wyrenderowany znacznik czasu, identyfikator żądania lub identyfikator śledzenia przypisany przez load balancer, zmienia się przy ponowieniu. Klucze muszą być budowane wyłącznie z niezmiennych faktów zdarzenia biznesowego.
  • Ponowne użycie klucza dla rzeczywiście różnej treści. Jeśli ponownie użyjesz order-confirm-1001, by wysłać również powiadomienie o wysyłce dla zamówienia 1001, dostawca może odtworzyć potwierdzenie i cicho porzucić mail o wysyłce. Jeden klucz na jedną odrębną logiczną wiadomość.
  • Zakładanie, że idempotencja zastępuje Twoją własną deduplikację. Cache kluczy dostawcy jest ograniczony w czasie (patrz niżej). Dla gwarancji poza tym oknem nadal potrzebujesz rekordu „już wysłane" na poziomie aplikacji.

Jak długo klucz idempotencji pozostaje ważny?

Klucze idempotencji są zwykle cache'owane przez 24 godziny, choć dokładny czas przechowywania różni się w zależności od dostawcy, niektórzy trzymają klucze przez zaledwie kilka minut, inni przez kilka dni, a niektórzy pozwalają to skonfigurować. Ponowienia w tym oknie zwracają oryginalną odpowiedź. Po wygaśnięciu ten sam klucz wyzwala nową wysyłkę, więc dokończ swoją logikę ponawiania z dużym zapasem przed oknem dostawcy. W praktyce rzadko jest to ograniczenie (większość ponowień kończy się w ciągu minut), ale ma to znaczenie dla długo żyjących kolejek: wiadomość, która siedzi w kolejce martwych listów przez dwa dni, a następnie jest odtwarzana z oryginalnym kluczem, wyśle świeży mail, ponieważ dostawca zapomniał o kluczu. Zaprojektuj retencję swojej kolejki z myślą o oknie dostawcy, a dla wszystkiego, co musi być dokładnie-raz poza tym oknem, trzymaj własną tabelę sent_emails(idempotency_key UNIQUE) jako trwałe zabezpieczenie.

Lista kontrolna idempotencji

  • Każda wysyłka niesie nagłówek Idempotency-Key.
  • Klucz jest wyprowadzony ze stabilnego identyfikatora biznesowego, a nie ze znacznika czasu lub świeżej losowej wartości.
  • Ta sama logiczna wysyłka generuje na nowo identyczny klucz po crashu lub restarcie.
  • Klucze mają przestrzeń nazw per tenant w systemach multi-tenant.
  • Okna ponawiania są wyraźnie poniżej okna retencji kluczy dostawcy, albo klucz jest utrwalany wraz z zakolejkowanym zadaniem.
  • Odrębne wysyłki (np. dwa osobne resety hasła albo potwierdzenie vs powiadomienie o wysyłce) produkują odrębne klucze.
  • 409 Conflict przy powtórzonym kluczu jest traktowany jako „oryginał w locie", a nie jako twardy błąd.
  • Ograniczenie unikalności na poziomie aplikacji zabezpiecza ograniczony cache dostawcy dla gwarancji dokładnie-raz-na-zawsze.

Kiedy powinienem ponowić nieudaną wysyłkę?

Tylko wtedy, gdy awaria jest przejściowa. Awarie przejściowe są normalne przy skali. Niezawodny nadawca traktuje je jako oczekiwane zdarzenia i odzyskuje automatycznie, ale musi ponawiać inteligentnie, odróżniając awarie warte ponowienia od awarii, które nigdy się nie powiodą, i rozkładając ponowienia w czasie, aby nie pogorszyć awarii.

Które błędy są warte ponowienia?

Nie każdy błąd powinien być ponawiany. Ponawianie źle sformułowanego żądania tylko marnuje czas i limit; ponawianie przejściowego błędu serwera zwykle się powodzi. Najpierw sklasyfikuj awarię:

Typ błęduPonowić?Uwagi
5xx (błąd serwera)✅ TakPrzejściowy, prawdopodobnie się rozwiąże
429 (rate limit)✅ TakPoczekaj na okno rate limitu; uszanuj Retry-After
408 (request timeout)✅ TakTimeout po stronie serwera; przejściowy
409 (konflikt na kluczu idempotencji)⚠️ Sprawdź ponownieOryginał w locie; odpytuj/wycofaj się, nie duplikuj
4xx (inny błąd klienta)❌ NieNajpierw napraw żądanie
Network timeout (ETIMEDOUT)✅ TakPrzejściowy
Connection reset (ECONNRESET)⚠️ OstrożnieMożna ponowić, ale mogło częściowo wysłać, polegaj na kluczu idempotencji
Connection refused (ECONNREFUSED)✅ TakEndpoint nie działa; wycofaj się
DNS failure (EAI_AGAIN, ENOTFOUND)✅ TakEAI_AGAIN przejściowy; ENOTFOUND może być błędem konfiguracji

Linia podziału to czyj to problem. 4xx (poza 429 i 408) oznacza, że Twoje żądanie jest błędne, nieprawidłowy adres e-mail, brakujące pole, niezweryfikowana domena. Ponawianie niezmienionego błędnego żądania będzie zawodziło identycznie za każdym razem i tylko spala zasoby. 5xx, 429, timeout lub czkawka DNS oznaczają, że coś na ścieżce jest tymczasowo niezdrowe, dokładnie ta sytuacja, dla której istnieją ponowienia.

Dlaczego idempotencja i ponowienia są nierozłączne?

Istnieje kategoria błędu, ECONNRESET, zerwane połączenie, timeout po tym, jak ciało żądania zostało w pełni wysłane, gdzie naprawdę nie możesz wiedzieć, czy efekt uboczny się wydarzył. To są dokładnie te przypadki, które muszą być ponowione dla niezawodności i nie mogą duplikować. Nie możesz mieć jednego bezpiecznie bez drugiego. Zasada brzmi: nigdy nie włączaj ponowień na ścieżce kodu, która nie niesie klucza idempotencji. Pętla ponawiania bez idempotencji nie jest funkcją niezawodności; jest generatorem zduplikowanych maili. Dlatego sekcja o idempotencji jest pierwsza w tym rozdziale, jest warunkiem wstępnym wszystkiego, co następuje.

Jak powinienem rozłożyć ponowienia w czasie?

  1. Próba 1 · natychmiast

    błąd przejściowy

  2. Próba 2 · ≈ 1 min

    błąd przejściowy

  3. Próba 3 · ≈ 5 min

    błąd przejściowy

  4. Próba 4 · ≈ 30 min

    błąd przejściowy

  5. Próba 5 · ≈ 2 h

    błąd przejściowy

  6. Poddaj się → dead-letter + alert

  • Wykładniczy backoff: każda przerwa dłuższa, + jitter, by uniknąć zsynchronizowanych ponowień.
  • Respektuj nagłówek Retry-After, jeśli dostawca go poda.
  • Ponawiaj tylko błędy przejściowe (5xx / timeout). Hard bounce = supresja, nigdy nie ponawiaj.
Przejściowa awaria wysyłki nie znika od razu. Ponawiaj z rosnącym odstępem, a po kilku próbach odłóż do kolejki martwych listów.

Gdy już ponawiasz, nie waluj w serwer. Czekaj progresywnie dłużej między próbami (wykładniczy backoff) i dodaj małe losowe przesunięcie (jitter), aby wielu klientów zawodzących w tym samym momencie nie ponawiało wszyscy zsynchronizowanie.

Kod dla zespołu technicznego (TypeScript)
async function sendWithRetry(emailData, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await emailClient.send(emailData);
    } catch (error) {
      if (!isRetryable(error) || attempt === maxRetries - 1) {
        throw error;
      }
      const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
      await sleep(delay + Math.random() * 1000); // Add jitter
    }
  }
}

function isRetryable(error) {
  return error.statusCode >= 500 ||
         error.statusCode === 429 ||
         error.statusCode === 408 ||
         error.code === 'ETIMEDOUT' ||
         error.code === 'ECONNRESET' ||
         error.code === 'ECONNREFUSED' ||
         error.code === 'EAI_AGAIN';
}

Przeczytaj logikę uważnie, bo to szczegóły czynią ją poprawną:

  • if (!isRetryable(error) || attempt === maxRetries - 1) throw error; pakuje dwa warunki wyjścia w jednej linii. Jeśli błąd nie nadaje się do ponowienia, poddaj się natychmiast (nie ma sensu czekać). Jeśli nadaje się do ponowienia, ale to była ostatnia próba, rzuć wyjątek, aby wywołujący mógł zakolejkować, zalogować lub zaalarmować. Nigdy nie połykaj ostatecznej awarii w ciszy.
  • Math.min(1000 * Math.pow(2, attempt), 30000) to część wykładnicza. Opóźnienie podwaja się przy każdej próbie (1000 * 2^attempt), a Math.min(..., 30000) ogranicza opóźnienie do 30 sekund, więc backoff nigdy nie rośnie absurdalnie długi.
  • + Math.random() * 1000 dodaje jitter. Bez niego każdy klient, który zawiódł w tym samym momencie, ponawia w tym samym momencie, produkując zsynchronizowany skok znany jako thundering herd (stado tętniących kopyt). Losowe przesunięcie rozkłada ponowienia w oknie i wygładza obciążenie na powracającym do zdrowia serwerze.

Harmonogram backoffu: 1s, potem 2s, potem 4s, potem 8s (z jitterem zapobiegającym thundering herd). Dla domyślnego maxRetries = 3 mają zastosowanie tylko pierwsze trzy opóźnienia; harmonogram pokazuje, jak się rozwija, gdy podnosisz limit, zawsze respektując ograniczenie 30 sekund.

Której strategii jitteru powinienem użyć?

Prosty + Math.random() * 1000 powyżej dodaje ograniczony jitter na wierzch pełnego backoffu, co jest w porządku dla ponowień w procesie. Dla systemów o wysokim fan-out (wiele workerów ponawiających wobec jednego dostawcy po wspólnej awarii) full jitter rozkłada obciążenie bardziej równomiernie i jest zalecanym domyślnym wyborem w literaturze systemów rozproszonych:

StrategiaWzór (próba n, baza b, ograniczenie c)Zachowanie
Brak jitterumin(c, b · 2ⁿ)Zsynchronizowane ponowienia; thundering herd
Równy / addytywny jittermin(c, b · 2ⁿ) + rand(0, j)Zachowana podłoga backoffu, małe rozproszenie (kod powyżej)
Full jitterrand(0, min(c, b · 2ⁿ))Najlepsze wygładzenie obciążenia; niektóre ponowienia odpalają niemal natychmiast
Decorrelated jittermin(c, rand(b, prev · 3))Samodostrajający się; rośnie od poprzedniego opóźnienia
Kod dla zespołu technicznego (TypeScript)
// Full jitter: a random point in [0, exponential cap].
function fullJitterDelay(attempt: number, base = 1000, cap = 30000): number {
  const exp = Math.min(cap, base * 2 ** attempt);
  return Math.random() * exp;
}

Dla synchronicznej ścieżki żądania równy jitter w zupełności wystarczy. Dla floty workerów w tle drenujących kolejkę preferuj full jitter, aby powracający do zdrowia dostawca widział płynne narastanie zamiast ściany zsynchronizowanych ponowień.

Czy powinienem respektować nagłówek Retry-After?

Tak, zawsze go preferuj. Gdy odpowiedź 429 (lub czasem 503) zawiera nagłówek Retry-After, użyj go zamiast swojego wyliczonego backoffu. Serwer mówi Ci dokładnie, jak długie jest okno jego rate limitu. Uszanowanie go rozwiązuje sytuację szybciej i sygnalizuje, że jesteś dobrze zachowującym się klientem, co chroni Twoją reputację u dostawcy.

Retry-After występuje w dwóch formatach zgodnie ze specyfikacją HTTP i musisz obsłużyć oba, delta-seconds i HTTP-date:

Kod dla zespołu technicznego (TypeScript)
// Retry-After is either "120" (seconds) or "Wed, 21 Oct 2026 07:28:00 GMT".
function parseRetryAfter(headerValue: string | null): number | null {
  if (!headerValue) return null;

  // Delta-seconds form.
  const seconds = Number(headerValue);
  if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);

  // HTTP-date form.
  const when = Date.parse(headerValue);
  if (!Number.isNaN(when)) return Math.max(0, when - Date.now());

  return null;
}

Gdy Retry-After jest obecny, czekaj max(parseRetryAfter(...), computedBackoff), nigdy mniej, niż poprosił serwer, ale też nigdy nie podcinaj swojej własnej podłogi backoffu.

Ile razy powinienem ponowić, zanim się poddam?

Trzy próby to rozsądny domyślny wybór dla synchronicznych ścieżek kodu, dlatego maxRetries = 3 powyżej. Poza kilkoma próbami w procesie lepiej jest przekazać mail do kolejki (omówionej niżej), aby ponowienia kontynuowały niezależnie od żądania użytkownika. Utrzymuj całkowity czas ponawiania w procesie krótki, najlepiej poniżej timeoutu żądania, abyś nigdy nie trzymał użytkownika za spinnerem, podczas gdy się wycofujesz.

Istnieją dwa odrębne budżety ponawiania, a ich mylenie powoduje albo zgubione maile, albo zablokowanych użytkowników:

  • Budżet w procesie: 2–3 szybkie próby, ograniczone timeoutem żądania HTTP. Jego zadaniem jest zatuszowanie pojedynczej przejściowej czkawki bez tego, by użytkownik to zauważył. Jeśli się wyczerpie, przekaż do kolejki, nie blokuj.
  • Budżet kolejki: wiele prób przez minuty-do-godzin, z dłuższym backoffem (na przykład 1m, 5m, 30m, 2h). Jego zadaniem jest przetrwanie awarii dostawcy. Gdy się wyczerpie, wiadomość trafia do kolejki martwych listów i wzywa człowieka.

Czy ponawiająca strona powinna kiedykolwiek przestać próbować jako flota?

Tak, pętla ponawiania per żądanie nie jest wystarczającą ochroną podczas pełnej awarii dostawcy. Jeśli tysiące workerów każdy ponawia trzy razy wobec martwego endpointu, walisz w niego w momencie, gdy wraca do zdrowia. Owiń wywołanie dostawcy w circuit breaker:

Kod dla zespołu technicznego (TypeScript)
// Minimal circuit breaker: trip open after N consecutive failures,
// reject fast while open, allow a trial request after a cooldown.
class CircuitBreaker {
  private failures = 0;
  private openedAt: number | null = null;

  constructor(
    private readonly threshold = 5,
    private readonly cooldownMs = 30_000,
  ) {}

  async call<T>(fn: () => Promise<T>): Promise<T> {
    if (this.openedAt !== null) {
      if (Date.now() - this.openedAt < this.cooldownMs) {
        throw new Error('circuit_open'); // fail fast, queue instead
      }
      this.openedAt = null; // half-open: allow one trial
    }
    try {
      const result = await fn();
      this.failures = 0;
      return result;
    } catch (err) {
      if (++this.failures >= this.threshold) this.openedAt = Date.now();
      throw err;
    }
  }
}

Gdy breaker jest otwarty, kieruj wysyłki prosto do kolejki, zamiast spalać ponowienia wobec endpointu, o którym już wiesz, że nie działa. To przekształca awarię dostawcy z „każde żądanie wolne i zawodzące" w „żądania zawodzą szybko i odzyskują automatycznie, gdy dostawca wraca".

Lista kontrolna ponawiania

  • Błędy są klasyfikowane przed ponowieniem. 4xx (poza 429/408) nigdy nie jest ponawiany.
  • Ponowienia działają tylko na ścieżkach kodu, które niosą klucz idempotencji.
  • Backoff jest wykładniczy i ograniczony (tutaj do 30s).
  • Jitter jest dodawany do każdego opóźnienia (full jitter dla workerów o wysokim fan-out).
  • Ostateczna awaria jest ponownie rzucana, a nie połykana.
  • Retry-After z odpowiedzi 429/503 jest respektowany, z parsowaniem zarówno formy sekundowej, jak i HTTP-date.
  • Budżety ponawiania w procesie i w kolejce są odrębne.
  • Circuit breaker zapobiega waleniu w endpoint przez całą flotę podczas awarii dostawcy.

Jak poprawnie zareagować na każdy rodzaj błędu?

Ponawianie to tylko połowa obsługi awarii. Druga połowa to poprawne reagowanie na każdy rodzaj błędu: naprawianie tego, co naprawialne, ponawianie tego, co przejściowe, i eskalowanie tego, co krytyczne.

Co oznaczają częste kody błędów i co powinienem zrobić?

KodZnaczenieAkcja
400Bad requestNapraw ładunek (nieprawidłowy e-mail, brakujące pole)
401UnauthorizedSprawdź klucz API
403ForbiddenSprawdź uprawnienia, weryfikację domeny
404Not foundSprawdź URL endpointu
408Request timeoutPonów z backoffem
409Conflict (idempotencja)Oryginał w locie; sprawdź ponownie, nie duplikuj
413Payload too largeZmniejsz załącznik/treść; nie ponawiaj bez zmian
422Validation errorNapraw dane żądania
429Rate limitedWycofaj się, uszanuj Retry-After
451Unavailable for legal reasonsOdbiorca/region zablokowany; wytłum, nie ponawiaj
500Server errorPonów z backoffem
502Bad gatewayPonów z backoffem
503Service unavailablePonów z backoffem; uszanuj Retry-After
504Gateway timeoutPonów z backoffem

Kilka z nich zasługuje na szczególną uwagę, bo są często błędnie diagnozowane:

  • 401 vs 403. 401 oznacza, że klucz API jest brakujący lub nieprawidłowy, uwierzytelnianie się nie powiodło. 403 oznacza, że jesteś uwierzytelniony, ale nie masz pozwolenia na zrobienie tego, najczęściej dlatego, że domena wysyłkowa nie jest zweryfikowana lub klucz nie ma uprawnienia. Nie rotuj klucza, gdy prawdziwą naprawą jest zweryfikowanie domeny.
  • 400 vs 422. Oba wskazują na błędne żądanie, ale 400 zwykle sygnalizuje źle sformułowany ładunek (nieparsowalna treść, zły content type), podczas gdy 422 sygnalizuje poprawnie sformułowane, ale semantycznie nieprawidłowe żądanie (składniowo poprawne pole z nieakceptowalną wartością). Naprawa się różni: 400 oznacza sprawdź, jak serializujesz; 422 oznacza sprawdź same dane.
  • 413 nie nadaje się do ponowienia bez zmian. Błąd „payload too large" oznacza, że załącznik lub treść HTML przekracza limit dostawcy na wiadomość. Ponawianie identycznego żądania zawiedzie identycznie; musisz zmniejszyć wiadomość (hostuj duże załączniki za linkiem), zanim może się powieść.
  • 429 i 408 to jedyne nadające się do ponowienia kody w zakresie 4xx. Wszystko inne tam to Twój błąd do naprawienia; 429 oznacza „zwolnij", 408 oznacza „serwer przestał czekać".

Czym błędy na poziomie API różnią się od błędów na poziomie SMTP?

Jeśli wysyłasz przez surowy SMTP zamiast HTTP API, masz do czynienia bezpośrednio z kodami odpowiedzi SMTP, i ten sam podział przejściowe-vs-trwałe ma zastosowanie. Mapuj je tak samo, jak mapujesz kody HTTP:

Kod SMTPKlasaZnaczenieAkcja
2502xx sukcesZaakceptowane do dostarczeniaGotowe
4214xx przejściowyUsługa niedostępna, zamykanie kanałuPonów z backoffem
4504xx przejściowySkrzynka zajęta / greylistingPonów po opóźnieniu
4514xx przejściowyLokalny błąd w przetwarzaniuPonów
4524xx przejściowyNiewystarczająca pamięć systemuPonów później
5505xx trwałySkrzynka niedostępna / odrzuconaNie ponawiaj; wytłum odbiorcę
5525xx trwałyRozmiar wiadomości przekracza limitZmniejsz wiadomość
5545xx trwałyTransakcja nieudana / zablokowanaNie ponawiaj; zbadaj

Zwróć uwagę na inwersję względem HTTP: w SMTP 4xx jest przejściowy („spróbuj ponownie później"), a 5xx jest trwały („odrzucone, nie ponawiaj"). 550 jest odpowiednikiem SMTP twardego bounce'a, adres jest błędny lub jesteś zablokowany, a ponawianie go uszkadza Twoją reputację. Przekazuj wyniki 550/554 do supresji dokładnie tak, jak zrobiłbyś to z webhookiem twardego bounce'a (patrz Zarządzanie listą).

Jak wygląda poprawna gałąź obsługi błędów?

Kod dla zespołu technicznego (TypeScript)
try {
  const result = await emailClient.send(emailData);
  await logSuccess(result.id, emailData);
} catch (error) {
  if (error.statusCode === 429 || error.statusCode === 503) {
    await queueForRetry(emailData, parseRetryAfter(error.retryAfter));
  } else if (error.statusCode === 408 || error.statusCode >= 500) {
    await queueForRetry(emailData);
  } else if (isHardBounce(error)) {
    await suppressRecipient(emailData.to, error);   // 550-class, invalid address
    await logFailure(error, emailData);
  } else {
    await logFailure(error, emailData);
    await alertOnCriticalEmail(emailData); // For password resets, etc.
  }
}

Ten wzorzec koduje cztery odrębne wyniki:

  1. Sukces: zaloguj identyfikator wiadomości dostawcy. Ten identyfikator to Twój klucz łączący. Łączy tę wysyłkę ze zdarzeniami webhooków dostarczenia, bounce'a i skargi, które otrzymasz później (patrz Webhooki i zdarzenia). Bez niego nie możesz skorelować przyszłego bounce'a z pierwotną wysyłką.
  2. Awaria przejściowa (429, 503, 408 lub 5xx): przekaż mail do kolejki do ponowienia, zamiast blokować bieżące żądanie. Dla 429/503 przekaż sparsowany Retry-After, aby kolejka czekała właściwą ilość czasu.
  3. Twardy bounce / nieprawidłowy odbiorca: adres jest błędny. Ponawianie nigdy nie pomaga, a wielokrotne wysyłanie na nieprawidłowe adresy rujnuje reputację Twojego nadawcy, więc wytłum odbiorcę natychmiast i zapisz dlaczego.
  4. Awaria trwała (inny 4xx): ponawianie jest bezcelowe, więc zaloguj awarię do diagnozy, a dla krytycznych maili zaalarmuj człowieka. Nieudany newsletter marketingowy może poczekać; nieudany reset hasła lub potwierdzenie płatności to prawdziwy incydent. Wywołanie alertOnCriticalEmail jest tym, co zamienia cichą stratę w wezwanie, na które można zareagować.

Zasada projektowa: najgorsza awaria to ta cicha. Każda gałąź powyżej produkuje trwały rekord, log sukcesu, zakolejkowane zadanie, wpis supresji albo log awarii plus alert. Nic nie wpada w szczeliny.

Jak utrzymać logi błędów użyteczne bez wycieku danych osobowych (PII)?

Ładunki maili zawierają dane osobowe (adresy odbiorców, imiona, czasem tokeny). Logi są czytane przez ludzi i wysyłane do systemów logowania osób trzecich, więc bądź rozmyślny:

  • Loguj identyfikator wiadomości dostawcy, klucz idempotencji, kod statusu i klasę błędu, to jest to, czego potrzebujesz do debugowania.
  • Zahaszuj lub skróć adres odbiorcy w logach (u***@example.com), zamiast przechowywać go w postaci jawnej, aby uszanować oczekiwania minimalizacji danych w reżimach omówionych w Zgodność prawna.
  • Nigdy nie loguj jednorazowych tokenów, linków resetujących ani pełnych treści HTML. Jeśli link resetujący wyląduje w Twoim agregatorze logów, to wyciek poświadczeń.
  • Dołącz identyfikator korelacji (identyfikator żądania + klucz idempotencji), aby pojedyncza logiczna wysyłka była śledzona w logu żądania, kolejce i zdarzeniach webhooków.

Kiedy potrzebuję kolejki zamiast wysyłki inline?

Dla krytycznych maili użyj kolejki, aby zapewnić dostarczenie nawet jeśli początkowa wysyłka się nie powiedzie. Kolejka przenosi mail z kruchej, synchronicznej ścieżki żądania do trwałego magazynu, gdzie może być ponawiany niezależnie od tego, czy żądanie HTTP użytkownika jest jeszcze żywe.

Co tak naprawdę daje mi kolejka?

Cztery konkretne korzyści:

  • Przetrwa restarty aplikacji. Zakolejkowany mail utrwalony w bazie danych lub brokerze jest nadal tam po deployu, crashu lub scale-downie. fetch w locie nie jest.
  • Automatyczna obsługa ponowień. Worker kolejki posiada pętlę backoffu, więc Twój handler żądania pozostaje prosty i szybki.
  • Zarządzanie rate limitem. Kolejka to naturalny throttle. Kontrolujesz tempo drenowania, więc nigdy nie przekraczasz limitów dostawcy podczas nagłego skoku.
  • Ślad audytowy. Każdy mail ma wiersz ze statusem i znacznikami czasu, co jest bezcenne przy debugowaniu pytań „czy użytkownik dostał maila?" oraz dla zgodności.

Jest też piąta, subtelniejsza korzyść: kolejka odsprzęga Twoje opóźnienie od opóźnienia dostawcy. Jeśli API dostawcy zwolni z 80ms do 4s podczas incydentu, wysyłka inline ciągnie w dół każde żądanie użytkownika. Wysyłka zakolejkowana wraca do użytkownika natychmiast i wchłania powolność dostawcy w tle.

Jaki jest najprostszy wzorzec kolejki?

Mała maszyna stanów na wierzchu jednej tabeli bazy danych:

  1. Zapisz mail do kolejki/bazy danych ze statusem „pending".
  2. Przetwarzaj kolejkę, spróbuj wysłać.
  3. Przy sukcesie: oznacz „sent", zapisz identyfikator wiadomości.
  4. Przy nadającej się do ponowienia awarii: zwiększ licznik ponowień, zaplanuj ponowienie.
  5. Przy trwałej awarii: oznacz „failed", zaalarmuj.

Ta maszyna stanów, pending → sent | failed z licznikiem ponowień, jest na tyle mała, że da się ją zaimplementować na wierzchu pojedynczej tabeli bazy danych, a jednak daje Ci trwałość, obserwowalność i ścieżkę martwych listów (wiersze utknięte w failed) za darmo. Sparuj ją z kluczami idempotencji powyżej: utrwalaj klucz wraz z zakolejkowanym zadaniem, aby odtworzenia po oknie retencji kluczy dostawcy były obsługiwane rozmyślnie, a nie przypadkiem.

Gotowy do skopiowania schemat outboxu

Najbardziej niezawodnym wzorcem dla usługi, która już ma relacyjną bazę danych, jest transactional outbox: zapisujesz wiersz maila w tej samej transakcji bazodanowej co zmiana biznesowa, która go wyzwala. Mail nie może zostać zgubiony (commituje się atomowo z zamówieniem) i nie może zostać wysłany dla wycofanego zamówienia.

Pokaż przykład
CREATE TABLE email_outbox (
  id              BIGSERIAL PRIMARY KEY,
  idempotency_key TEXT        NOT NULL UNIQUE,
  recipient       TEXT        NOT NULL,
  template        TEXT        NOT NULL,
  payload         JSONB       NOT NULL,
  status          TEXT        NOT NULL DEFAULT 'pending'
                  CHECK (status IN ('pending','sending','sent','failed')),
  attempts        INT         NOT NULL DEFAULT 0,
  max_attempts    INT         NOT NULL DEFAULT 8,
  next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  provider_msg_id TEXT,
  last_error      TEXT,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Workers claim due jobs without stepping on each other.
CREATE INDEX idx_email_outbox_due
  ON email_outbox (next_attempt_at)
  WHERE status IN ('pending','failed') AND attempts < max_attempts;

Worker zgłasza zadanie do realizacji atomowo z FOR UPDATE SKIP LOCKED, więc wiele workerów może drenować tabelę współbieżnie bez podwójnej wysyłki:

Kod dla zespołu technicznego (TypeScript)
// One claim-and-send cycle. Run this in a loop across N workers.
async function drainOnce(db, emailClient): Promise<boolean> {
  const job = await db.tx(async (t) => {
    const rows = await t.query(`
      SELECT * FROM email_outbox
      WHERE status IN ('pending','failed')
        AND attempts < max_attempts
        AND next_attempt_at <= now()
      ORDER BY next_attempt_at
      FOR UPDATE SKIP LOCKED
      LIMIT 1
    `);
    if (rows.length === 0) return null;
    const r = rows[0];
    await t.query(`UPDATE email_outbox SET status='sending', updated_at=now() WHERE id=$1`, [r.id]);
    return r;
  });

  if (!job) return false; // nothing due

  try {
    const result = await emailClient.send(job.payload, {
      headers: { 'Idempotency-Key': job.idempotency_key },
    });
    await db.query(
      `UPDATE email_outbox SET status='sent', provider_msg_id=$2, updated_at=now() WHERE id=$1`,
      [job.id, result.id],
    );
  } catch (error) {
    const attempts = job.attempts + 1;
    const giveUp = !isRetryable(error) || attempts >= job.max_attempts;
    // Queue-budget backoff: minutes, not milliseconds. Full jitter.
    const backoffMs = Math.min(2 ** attempts * 60_000, 2 * 60 * 60_000) * Math.random();
    await db.query(
      `UPDATE email_outbox
         SET status=$2, attempts=$3, last_error=$4,
             next_attempt_at = now() + ($5 || ' milliseconds')::interval,
             updated_at=now()
       WHERE id=$1`,
      [job.id, giveUp ? 'failed' : 'pending', attempts, String(error), Math.round(backoffMs)],
    );
    if (giveUp) await alertOnDeadLetter(job, error);
  }
  return true;
}

Klucz idempotencji jest siatką bezpieczeństwa dla jedynego wyścigu, którego ten projekt nie może w pełni uniknąć: worker oznacza wiersz sending, wysyła pomyślnie, a następnie crashuje, zanim zapisze sent. Po odzyskaniu wiersz wygląda na utknięty i zostaje ponowiony, ale ponieważ ten sam klucz jest odtwarzany, dostawca deduplikuje i żaden drugi mail nie wychodzi. Crash staje się nieszkodliwy, co jest całym sensem.

Jak obsługiwać kolejkę martwych listów (dead-letter queue)?

Zadanie, które wyczerpuje max_attempts, jest w stanie martwego listu (status='failed', attempts >= max_attempts). To nie jest miejsce, gdzie wiadomości idą umrzeć w ciszy; to kolejka robocza dla ludzi:

  • Alarmuj przy wejściu, a nie na dashboardzie, którego nikt nie ogląda. Reset hasła docierający do kolejki martwych listów to incydent.
  • Uczyń odtworzenie jednolinijkowcem. Zresetuj status='pending', attempts=0, next_attempt_at=now(), gdy bazowa przyczyna (awaria dostawcy, zła konfiguracja) zostanie naprawiona. Ponieważ klucz idempotencji jest zachowany, odtworzenie jest bezpieczne, jeśli zdarzy się w oknie kluczy dostawcy; poza tym oknem Twój własny UNIQUE(idempotency_key) w outboxie plus zabezpieczenie status='sent' zapobiega prawdziwemu duplikatowi.
  • Odróżnij zatrute wiadomości (poison messages) od ofiar awarii. Wiadomość, która zawodzi z trwałym 4xx (zły adres, zbyt duży ładunek), powinna trafić prosto do failed po jednej próbie, a nie spalać ośmiu ponowień. Tylko błędy przejściowe powinny zużywać pełny budżet ponawiania.

Które maile faktycznie potrzebują kolejki?

Nie każdy mail jej potrzebuje. Mail re-engagementu „tęsknimy za Tobą" w trybie best-effort może być wysłany inline. Ale wszystko, na co użytkownik czeka (kody logowania, potwierdzenia płatności, potwierdzenia) lub cokolwiek wymaganego prawnie korzysta z trwałości, którą zapewnia kolejka. Prosta zasada: jeśli pominięta wysyłka stworzyłaby zgłoszenie do supportu, lukę bezpieczeństwa lub problem ze zgodnością, zakolejkuj ją.

MailInline czy kolejka?Dlaczego
Jednorazowy kod logowaniaKolejka (fast lane)Krytyczny dla bezpieczeństwa; użytkownik czeka; nie może zostać zgubiony
Reset hasłaKolejka (fast lane)To samo; również unika duplikatów przy ponowieniu
Potwierdzenie zamówienia / płatnościKolejkaWażny prawnie i reputacyjnie; użytkownik się go spodziewa
Zamówienie wysłane / statusKolejkaUżytkownik czeka; wysoki koszt zgłoszeń do supportu, jeśli zgubione
Newsletter marketingowyKolejka (bulk lane)Wolumen wymaga throttlingu wobec rate limitów
Re-engagement „tęsknimy za Tobą"Inline akceptowalneBest-effort; pojedyncza strata jest nieszkodliwa

Praktycznym udoskonaleniem są dwie kolejki o różnych tempach drenowania: niskolatencyjny „fast lane" dla maili transakcyjnych blokujących użytkownika i throttlowany „bulk lane" dla marketingu. To powstrzymuje kampanię do 200 000 odbiorców od zagłodzenia resetu hasła użytkownika stojącego za nią, realny tryb awarii, gdy obie dzielą jedną kolejkę. Podział mapuje się też czysto na rozróżnienie transakcyjne-vs-marketingowe w Typy maili oraz na radę dotyczącą reputacji osobnej subdomeny w Dostarczalność.

Lista kontrolna kolejkowania

  • Krytyczne maile są zapisywane do trwałego magazynu przed próbą wysyłki (najlepiej transactional outbox commitujący się ze zmianą biznesową).
  • Każde zadanie niesie swój klucz idempotencji i licznik ponowień.
  • Workery zgłaszają zadania z FOR UPDATE SKIP LOCKED (lub odpowiednikiem), więc współbieżne drenowanie jest bezpieczne.
  • Pomyślne wysyłki zapisują identyfikator wiadomości dostawcy.
  • Trwale nieudane zadania lądują w widocznym stanie „failed" i wyzwalają alert.
  • Zatrute (trwały 4xx) wiadomości trafiają do martwych listów po jednej próbie, a nie po pełnym budżecie ponawiania.
  • Ruch transakcyjny i bulk używają osobnych pasów / temp drenowania.
  • Tempo drenowania respektuje rate limity dostawcy.

Jak pozostać w ramach rate limitów dostawcy i rozgrzać wysyłkę?

Kolejka jest throttlem tylko jeśli faktycznie ją throttlujesz. Dostawcy egzekwują limity wysyłki na sekundę, na minutę i dzienne, a ich przekroczenie produkuje 429, które, ponawiane naiwnie, kuli się jak śnieżka.

Jak ukształtować ruch wychodzący do limitu dostawcy?

Drenuj kolejkę za pomocą limitera typu token bucket o rozmiarze dopasowanym do udokumentowanego tempa dostawcy. Token bucket pozwala na krótkie skoki do rozmiaru kubełka, jednocześnie utrzymując średnią długoterminową na poziomie tempa uzupełniania:

Kod dla zespołu technicznego (TypeScript)
// Token bucket: `capacity` burst, `refillPerSec` steady-state rate.
class TokenBucket {
  private tokens: number;
  private last = Date.now();
  constructor(private capacity: number, private refillPerSec: number) {
    this.tokens = capacity;
  }
  tryRemove(): boolean {
    const now = Date.now();
    this.tokens = Math.min(
      this.capacity,
      this.tokens + ((now - this.last) / 1000) * this.refillPerSec,
    );
    this.last = now;
    if (this.tokens >= 1) { this.tokens -= 1; return true; }
    return false;
  }
}

Wymiaruj kubełek konserwatywnie poniżej twardego limitu dostawcy, aby przejściowe skoki miały zapas, zanim trafisz na 429. Gdy już trafisz na 429, potraktuj to jako sygnał do tymczasowego obniżenia tempa drenowania (additive-increase / multiplicative-decrease), a nie tylko do ponowienia tej jednej wiadomości.

Dlaczego stopniowe narastanie wolumenu ma znaczenie dla nowej domeny lub IP?

Zupełnie nowa domena wysyłkowa lub dedykowane IP nie ma reputacji. Wysłanie 500 000 maili pierwszego dnia z zimnego IP wygląda dokładnie jak skompromitowany host dla odbierających dostawców skrzynek pocztowych, a duża część zostanie odroczona lub trafi do folderu spam. Warm-up oznacza stopniowe narastanie wolumenu przez dni do tygodni, aby reputacja budowała się najpierw z zaangażowanymi odbiorcami. To kwestia dostarczalności, która ogranicza Twoją architekturę wysyłki: tempo drenowania Twojej kolejki musi być zdolne do ograniczania dziennego wolumenu na tożsamość wysyłkową podczas warm-upu, a nie tylko do throttlingu skoków na sekundę. Pełna krzywa warm-upu i mechanika reputacji żyją w Dostarczalność; architektoniczny wniosek tutaj jest taki, że Twój limiter potrzebuje dziennego sufitu, a nie tylko sufitu na sekundę.

Co z uwierzytelnianiem, wypisaniem się i regułami dla masowych nadawców?

Niezawodność wysyłki to zmarnowany wysiłek, jeśli dostawca akceptuje Twoją wiadomość, ale dostawcy skrzynek pocztowych odrzucają ją za niepowodzenie uwierzytelniania lub polityki masowego nadawcy. To nie są „dodatki do dostarczalności", od lutego 2024 są to twarde warunki wstępne wysyłki przy dowolnym wolumenie do głównych dostawców skrzynek pocztowych, więc należą do każdej uczciwej dyskusji o tym, czy wysyłka faktycznie się powiedzie.

Czego wymagają reguły masowych nadawców Gmaila, Yahoo i Microsoftu?

Z mocą obowiązującą od lutego 2024, Gmail i Yahoo (z Microsoftem podążającym tym samym kierunkiem dla Outlook.com / Hotmail) egzekwują wymagania wobec masowych nadawców (zwykle ujmowane wokół 5000+ wiadomości dziennie do ich użytkowników, choć podstawowy poziom uwierzytelniania dotyczy wszystkich):

  1. Uwierzytelnij swoją pocztę za pomocą SPF oraz DKIM, z opublikowaną polityką DMARC (co najmniej p=none).
  2. Utrzymuj wskaźnik skarg na spam poniżej 0,3% mierzony w narzędziach postmaster, najlepiej znacznie poniżej 0,1%. Powyżej 0,3% zobaczysz throttling i trafianie do folderu spam.
  3. Wspieraj wypisanie jednym kliknięciem na poczcie marketingowej/masowej zgodnie z RFC 8058, i honoruj żądania wypisania w ciągu dwóch dni.
  4. Wysyłaj z uwierzytelnionych, wyrównanych domen, domena From musi być wyrównana z Twoim DKIM/SPF, aby DMARC przeszedł.

To mapuje się bezpośrednio na niezawodność: wiadomość, która zawodzi uwierzytelnianie, jest faktycznie niewiarygodną wysyłką, ponieważ zostanie cicho odfiltrowana niezależnie od tego, jak czysto powiodło się Twoje wywołanie API.

Jakie są standardy uwierzytelniania e-maili, według RFC?

StandardRFCCo dowodzi / robi
SPFRFC 7208Autoryzuje, które IP mogą wysyłać dla domeny (envelope MAIL FROM)
DKIMRFC 6376Kryptograficznie podpisuje wiadomość; dowodzi, że nie została zmieniona i pochodzi z domeny
DMARCRFC 7489Wiąże SPF/DKIM z widocznym From; mówi odbiorcom, co zrobić przy niepowodzeniu i gdzie raportować
ARCRFC 8617Zachowuje wyniki uwierzytelniania przez forwardery/listy mailingowe
MTA-STSRFC 8461Egzekwuje TLS dla przychodzącego SMTP do Twojej domeny
TLS-RPTRFC 8460Raportuje Ci niepowodzenia negocjacji TLS
List-Unsubscribe (one-click)RFC 8058Wypisanie jednym kliknięciem przez nagłówek + POST
BIMI(szkic / specyfikacja branżowa)Wyświetla Twoje zweryfikowane logo marki; wymaga egzekwowania DMARC, często VMC

Gotowe do skopiowania rekordy DNS

To są ilustracyjne rekordy dla domeny example.com wysyłającej przez generycznego dostawcę. Zastąp selektory, include'y i adresy raportowania własnymi.

Rekordy DNS do skopiowania
; SPF (RFC 7208), TXT at the root. Authorize your provider's sending hosts.
; Keep to a single SPF record; stay under the 10 DNS-lookup limit.
example.com.            IN TXT "v=spf1 include:_spf.your-provider.example -all"

; DKIM (RFC 6376), public key at a provider-specific selector.
; The provider gives you the selector (here "s1") and the key.
s1._domainkey.example.com. IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...IDAQAB"

; DMARC (RFC 7489), start at p=none for monitoring, then move to quarantine/reject.
_dmarc.example.com.     IN TXT "v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com; adkim=s; aspf=s; pct=100"

; MTA-STS (RFC 8461), declares you want enforced TLS for inbound mail.
_mta-sts.example.com.   IN TXT "v=STSv1; id=20260101T000000Z"

; TLS-RPT (RFC 8460), where TLS failure reports are sent.
_smtp._tls.example.com. IN TXT "v=TLSRPTv1; rua=mailto:tls-reports@example.com"

; BIMI, published only after DMARC is at enforcement (quarantine/reject).
default._bimi.example.com. IN TXT "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem"

Wdrażaj DMARC rozmyślnie: opublikuj najpierw p=none i czytaj zagregowane raporty (rua), aż SPF i DKIM będą wyrównane dla wszystkich legalnych strumieni, następnie awansuj p=none → p=quarantine → p=reject, opcjonalnie narastając z pct=. Przejście prosto do p=reject przed wyrównaniem strumieni odrzuci Twoją własną legalną pocztę.

Weryfikacja rekordów za pomocą dig

Polecenia do weryfikacji
# SPF, confirm exactly one v=spf1 TXT record exists.
dig +short TXT example.com | grep spf1

# DKIM, check the public key at your provider's selector.
dig +short TXT s1._domainkey.example.com

# DMARC, confirm policy and reporting addresses.
dig +short TXT _dmarc.example.com

# MTA-STS policy file is fetched over HTTPS, but the TXT advertises it:
dig +short TXT _mta-sts.example.com
curl -s https://mta-sts.example.com/.well-known/mta-sts.txt

# TLS-RPT reporting endpoint.
dig +short TXT _smtp._tls.example.com

Jak zaimplementować wypisanie jednym kliknięciem (RFC 8058)?

Wypisanie jednym kliknięciem wymaga obu nagłówków: List-Unsubscribe (z co najmniej jednym URL https:, sam mailto: nie spełnia wymagania one-click) oraz List-Unsubscribe-Post. Gdy oba są obecne, dostawcy skrzynek pocztowych renderują wbudowaną kontrolkę „wypisz się" i wykonują POST do Twojego URL, gdy użytkownik ją kliknie, bez dalszej interakcji.

List-Unsubscribe: <https://example.com/u/unsub?token=OPAQUE_TOKEN>, <mailto:unsubscribe@example.com?subject=unsub>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
Kod dla zespołu technicznego (TypeScript)
// The endpoint must accept a POST (not a GET) and unsubscribe immediately,
// without requiring a login or a confirmation page. RFC 8058.
app.post('/u/unsub', async (req, res) => {
  // Mailbox providers send: List-Unsubscribe=One-Click in the body.
  const token = req.query.token as string;
  const sub = verifyUnsubToken(token); // opaque, signed, identifies recipient+list
  if (!sub) return res.sendStatus(400);

  await suppressRecipient(sub.recipient, { reason: 'one-click-unsubscribe', list: sub.list });
  return res.sendStatus(200); // honor within 2 days; immediate is best
});

Dwie zasady, na które ludzie się nadziewają: token musi być nieprzejrzysty (opaque) i podpisany, aby atakujący nie mógł wypisać dowolnych adresów przez zgadywanie ID, a handler musi być prawdziwym POST-em, który działa natychmiast, przepływ oparty na GET typu „kliknij tutaj, potem potwierdź" nie spełnia RFC 8058 i nie zasłuży na natywny przycisk wypisania. Mechanika wypisania i supresji jest dalej omówiona w Zgodność prawna i Zarządzanie listą.

Jaki timeout ustawić na wywołaniu API poczty?

Ustaw timeout od 10 do 30 sekund i zawsze go sprzątaj. Żądanie bez timeoutu może wisieć w nieskończoność, jeśli dostawca jest wolny lub sieć utknie, blokując połączenie, wątek lub naliczany czas trwania funkcji serverless, i (gorzej) blokując użytkownika za spinnerem bez rozwiązania.

Kod dla zespołu technicznego (TypeScript)
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);

try {
  await emailClient.send(emailData, { signal: controller.signal });
} finally {
  clearTimeout(timeout);
}

Dwa szczegóły czynią to poprawnym:

  • AbortController daje Ci uchwyt do anulowania żądania w locie. Po 10 sekundach (10000 ms) controller.abort() odpala i wysyłka jest odrzucana, zamiast wisieć w nieskończoność.
  • Blok finally czyści timer we wszystkich przypadkach: sukcesu, awarii lub abort. Zapomnienie o tym przecieka timery i, w niektórych runtime'ach, utrzymuje proces przy życiu dłużej niż to konieczne.

Zalecany zakres: od 10 do 30 sekund dla wywołań API poczty. Poniżej około 10s ryzykujesz przerwanie żądań, które powiodłyby się na nieco wolnym połączeniu; powyżej około 30s trzymasz zasoby zajęte długo po momencie, w którym ponowienie byłoby szybsze.

Które timeouty faktycznie mają znaczenie, i dlaczego jedna liczba nie wystarcza?

Pojedynczy ogólny timeout to narzędzie tępe jak siekiera. Niezawodny klient HTTP rozróżnia kilka faz, ponieważ utknięcie w każdej z nich oznacza coś innego:

TimeoutCo ograniczaTypowa wartość
DNS resolutionRozwiązanie nazwy hosta dostawcy2–5s
TCP connectUstanowienie gniazda3–5s
TLS handshakeNegocjacja szyfrowania3–5s
Time-to-first-byteSerwer zaczyna odpowiadać5–10s
Overall requestCałkowity czas zegarowy wywołania10–30s

Kluczowe rozróżnienie jest między connect timeout a request (read) timeout. Połączenie, które nigdy się nie ustanawia (ECONNREFUSED, connect timeout), prawie na pewno nie wysłało maila, więc jednoznacznie bezpiecznie je ponowić. Żądanie, które przekracza timeout po przesłaniu ciała, to niebezpieczny przypadek, mail mógł lub mógł nie zostać zaakceptowany, i jest dokładnie powodem, dla którego klucz idempotencji jest obowiązkowy, zanim włączysz ponowienia. Tam, gdzie Twój klient HTTP to wspiera, ustaw krótki connect timeout i osobny, dłuższy read timeout zamiast jednej połączonej wartości.

Jak timeouty współgrają z ponowieniami?

Komponują się czysto. Timeout produkuje nadający się do ponowienia błąd (ETIMEDOUT w kontroli isRetryable powyżej), więc żądanie, które wisi, jest przerywane po 10s, a następnie ponawiane z backoffem, co jest dokładnie zachowaniem, którego chcesz. Timeout ogranicza każdą indywidualną próbę, a pętla ponawiania decyduje, czy spróbować ponownie.

Jedna subtelność: upewnij się, że Twój całkowity budżet (próby × timeout + opóźnienia backoffu) pozostaje poniżej dowolnego obejmującego deadline'u. Jeśli jesteś wewnątrz handlera żądania HTTP z 30s timeoutem bramki, trzy próby po 10s plus backoff przekroczą go, bramka zabije połączenie, a użytkownik dostanie błąd, mimo że Twoje ponowienia mogłyby się jeszcze powieść. Gdy budżet w procesie nie mieści się pod obejmującym deadline'em, wykonaj jedną szybką próbę i przekaż resztę do kolejki.

Jak to wszystko składa się razem w jedną wysyłkę?

Cztery filary wzajemnie się wzmacniają, a produkcyjny nadawca używa ich wszystkich naraz:

  1. Idempotencja czyni ponowienia bezpiecznymi. Bez niej logika ponawiania tworzyłaby duplikaty.
  2. Logika ponawiania sprawia, że awarie przejściowe odzyskują. Bez niej każda czkawka sieci staje się zgubionym mailem.
  3. Obsługa błędów sprawia, że każda klasa awarii dostaje właściwe traktowanie: napraw, ponów, wytłum lub eskaluj.
  4. Kolejkowanie czyni całość trwałą, przetrwając crashe i odsprzęgając wysyłkę od ścieżki żądania.
  5. Timeouty utrzymują indywidualne próby ograniczone, czysto zasilając pętlę ponawiania.
  6. Rate limiting i uwierzytelnianie sprawiają, że zaakceptowana wiadomość jest faktycznie dostarczalna, a nie tylko zaakceptowana.

Pojedyncza krytyczna wysyłka wygląda więc tak: zapisz oczekujące zadanie do outboxu w tej samej transakcji co zmiana biznesowa, worker zgłasza je z SKIP LOCKED i próbuje wysyłki z kluczem idempotencji i 10s timeoutem, przy 5xx, 429, 503 lub timeoucie wycofuje się (budżet kolejki, full jitter) i ponawia, przy twardym bouncie klasy 550 wytłumia odbiorcę, przy sukcesie zapisuje identyfikator wiadomości, a przy wyczerpaniu budżetu ponawiania trafia do martwych listów i alarmuje. Gwarancja end-to-end to dostarczenie dokładnie-raz z automatycznym odzyskiwaniem i bez cichych awarii.

Przejście krok po kroku przez przypadek niejednoznacznej awarii

Aby zobaczyć, dlaczego elementy się zazębiają, prześledź najgorszy przypadek, timeout po tym, jak żądanie zostało wysłane:

  1. Worker zgłasza wiersz outboxu 42 (klucz K), oznacza go sending, ustawia 10s timeout.
  2. Dostawca odbiera żądanie, akceptuje wiadomość, ale odpowiedź ginie; timeout workera odpala (ETIMEDOUT).
  3. isRetryable zwraca true; worker planuje ponowienie z budżetu kolejki, a wiersz wraca do pending.
  4. Później worker ponownie zgłasza wiersz 42 i wysyła ponownie z tym samym kluczem K.
  5. Dostawca rozpoznaje K, zwraca oryginalny identyfikator wiadomości bez ponownej wysyłki.
  6. Worker zapisuje sent + identyfikator wiadomości. Dokładnie jeden mail dotarł do odbiorcy.

Usuń którykolwiek pojedynczy filar, a to się załamuje: bez timeoutu worker wisi w nieskończoność; bez ponowienia mail jest zgubiony; bez idempotencji krok 5 wysyła duplikat; bez trwałej kolejki crash między krokami 2 a 4 traci wiadomość całkowicie.

Częste błędy

Skonsolidowana lista awarii, które powtarzają się najczęściej w produkcyjnych systemach pocztowych:

  • Ponawianie bez klucza idempotencji. Pojedynczy najczęstszy sposób na spamowanie użytkowników. Każda ścieżka ponawiania potrzebuje klucza.
  • Generowanie klucza idempotencji wewnątrz pętli ponawiania. Świeży klucz na próbę jest identyczny jak brak klucza.
  • Traktowanie API 200 jako „dostarczone". Oznacza „zaakceptowane przez dostawcę". Dostarczenie jest obserwowane przez webhooki (Webhooki i zdarzenia).
  • Brak jitteru na backoffie. Zsynchronizowane ponowienia po awarii tworzą thundering herd, który ponownie kładzie dostawcę.
  • Ignorowanie Retry-After. Wycofujesz się mniej, niż poprosił serwer, dostajesz mocniejszy rate limit i wyglądasz jak zły aktor.
  • Ponawianie twardych bounce'ów. Wielokrotne wysyłanie na adres 550/nieprawidłowy szarpie reputację nadawcy; zamiast tego wytłum.
  • Wysyłka inline na ścieżce żądania. Wolny dostawca ciągnie w dół każde żądanie użytkownika, a crash traci mail.
  • Dzielenie jednej kolejki dla marketingu i transakcyjnych. Kampania zagładza krytyczne czasowo resety hasła.
  • Logowanie pełnych ładunków. Linki resetujące i tokeny w Twoim agregatorze logów to wyciek poświadczeń.
  • Brak dziennego sufitu wolumenu na nowej domenie/IP. Strzelanie z zimnego startu obniża dostarczalność, zanim reputacja może się zbudować.
  • DMARC prosto do p=reject przed wyrównaniem. Odrzucasz swoją własną legalną pocztę.
  • Wypisanie oparte na GET. Zawodzi RFC 8058; tracisz natywny przycisk one-click i ryzykujesz kary dla masowych nadawców.

Przewodnik rozwiązywania problemów

ObjawPrawdopodobna przyczynaGdzie szukać
Użytkownicy zgłaszają zduplikowane mailePonowienia bez (lub z niestabilnymi) kluczami idempotencjiTen rozdział, generowanie kluczy
Maile „wysłane" według logów, ale nigdy nie docierająMylenie akceptacji API z dostarczeniem; lub niepowodzenie uwierzytelnianiaWebhooki i zdarzenia, Dostarczalność
Nagły skok 429Tempo drenowania przekracza limit dostawcy; brak token bucketaSekcja o rate limitingu powyżej
Dostawca zwraca 403 na każdą wysyłkęDomena wysyłkowa nie zweryfikowana / klucz nie ma uprawnieniaSekcja o kodach błędów; zweryfikuj DNS
Dużo 550 / twardych bounce'ówWysyłka na nieprawidłowe/przestarzałe adresyZarządzanie listą
Poczta ląduje w spamie mimo czystych wysyłekBrak wyrównania SPF/DKIM/DMARC; wskaźnik skarg > 0,3%Sekcja o uwierzytelnianiu; Dostarczalność
Wiadomości utknięte w kolejce martwych listówAwaria dostawcy lub zatrute ładunkiObsługa martwych listów powyżej
Użytkownicy czekają na spinnerze podczas wysyłekWysyłka inline; brak kolejki; zbyt długi timeoutSekcje o kolejkowaniu i timeoutach
Brak przycisku wypisania jednym kliknięciem w GmailuBrak List-Unsubscribe-Post lub URL https:Sekcja o RFC 8058 powyżej

Lista kontrolna niezawodności end-to-end

  • Klucze idempotencji na każdej wysyłce, wyprowadzone ze stabilnych zdarzeń biznesowych.
  • Ponowienia tylko na ścieżkach idempotentnych; sklasyfikowane, wykładnicze, z jitterem i ograniczone.
  • Retry-After respektowany na 429/503 (zarówno forma sekundowa, jak i HTTP-date).
  • Każda gałąź błędu loguje, kolejkuje, wytłumia lub alarmuje. Nic nie jest ciche.
  • Twarde bounce'y (klasa 550) zasilają supresję, a nie ponowienia.
  • Krytyczne maile przechodzą przez trwałą kolejkę (najlepiej transactional outbox) z widocznym stanem failed i alarmowaniem martwych listów.
  • Ruch transakcyjny i bulk używają osobnych pasów.
  • Tempo drenowania respektuje limity dostawcy na sekundę i dzienne; nowe domeny/IP są rozgrzewane.
  • Timeouty (od 10 do 30s) na każdym wywołaniu API, ze sprzątaniem w finally i osobnymi connect/read timeoutami.
  • Całkowity budżet ponawiania mieści się pod dowolnym obejmującym deadline'em żądania/bramki.
  • Identyfikatory wiadomości dostawcy zapisywane, aby korelować ze zdarzeniami webhooków.
  • SPF (RFC 7208), DKIM (RFC 6376) i DMARC (RFC 7489) opublikowane i wyrównane; wskaźnik skarg poniżej 0,3%.
  • Wypisanie jednym kliknięciem (RFC 8058) zaimplementowane jako podpisany endpoint POST.

Co powinienem przeczytać dalej?

Jeśli dzieje się to na produkcji…

Nie zgaduj. Sprawdź to

Umów przegląd 30 minPrzyjrzymy się, gdzie dziś trafia Twoja poczta i co trzeba naprawić (bez zobowiązań).Umów rozmowę o dostarczalności

Widziałem to w praktyce: przeczytaj case studies