Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Jádro / Odesílání e-mailů

Odesílání e-mailů#

E-mail se v tomhle systému nikdy neposílá přímo z presenteru. Manager zavolá jednu ze tří metod z SendEmailTrait, ta zprávu sestaví a podle nastavení ji buď zařadí do fronty, nebo odešle hned. Frontu pak rozesílá cron.

Kód: app/Core/Traits/Api/System/Models/Managers/SendEmailTrait.php (šablony, fronta) a MailerTrait.php vedle něj (výběr maileru a nouzová pojistka; používá ho i fronta).

Tři metody#

Metoda Kdy ji použít
sendEmail($langId, $emailKey, $to, $values, …) výchozí volba — šablona z databáze podle klíče, v jazyce příjemce
sendEmailFromText($subject, $body, $from, $to, $values, …) text sestavený v kódu; šablona v databázi neexistuje
sendEmailToAll($emailKey, $recipients, $values, …) hromadné rozeslání téže šablony více příjemcům

Všechny berou $values (zástupné texty do šablony), $files (přílohy) a $replyTos.

Adresa pro odpověď se hodí tam, kde má příjemce psát někomu jinému než systému

Typicky u odpovědi na inzerát: zprávu odešle systém, ale odpovědět se má inzerentovi.

E-mail z frontu#

Front modul e-mail sám neposílá, šablonu nečte a do fronty nezapisuje. Komponenta zavolá Front\System\…\EmailManager::sendEmail($langId, $emailKey, $to, $values, $replyTos) a ten předá data interní akci :Api:System:Email:send (EmailPresenter::internalActionSend()). Šablonu, vykreslení i zápis do fronty udělá Api EmailManager, výsledek se vrátí jako Result.

Vlastnost Proč
akce nemá actionSend(), zvenčí končí 404 s vnější routou by z endpointu byla otevřená rozesílačka; hlídá to FrontApiSurfacePolicyTest
front nepotřebuje :Api:System:Email:getBy ani :Api:System:EmailQueue:save oba klíče zůstávají jen administrátorovi; EmailQueue:save má vnější CRUD routu
přílohy se nepředávají objekty File přes bridge neprojdou
prázdný langId, emailKey nebo příjemce INVALID_DATA, nic se neodešle

Nevracejte do frontu SendEmailTrait

Trait čte šablonu přes getBy (jen pro administrátora), takže ve vynucujícím režimu RBAC šablonu nenajde a formulář tiše nic neodešle; zápis do fronty by navíc potřeboval EmailQueue:save. Tok, který potřebuje přílohy nebo vlastní pravidla, dostane vlastní Api akci (vzor :Api:System:User:sendNewPassword), ne parametr navíc u send.

Kam zpráva jde: nastavení 1/email.sending.mode#

Tohle je jediné místo, které rozhoduje mezi frontou a okamžitým odesláním:

Hodnota Chování
1 všechno do fronty (výchozí)
2 do fronty jen nepřednostní; přednostní odchází okamžitě
jinak vše se odesílá okamžitě, fronta se nepoužije

Proč vůbec fronta

Odeslání trvá vteřiny. Objednávka posílající dva e-maily by se odbavovala znatelně déle — a při výpadku poštovního serveru by spadla celá. Takhle jen počká zpráva.

Přednostní zpráva má smysl jen u režimu 2

Příznak preferred odlišuje potvrzení registrace nebo obnovu hesla od hromadné rozesílky. Při email.sending.mode: 1 jde do fronty i ta — a předběhne newsletter až tam, ne dřív.

Fronta#

Entita EmailQueue nese příjemce, odesílatele, předmět, tělo, přílohy, adresy pro odpověď a k tomu provozní pole:

Pole Význam
sent, sendDatetime odesláno a kdy
attempts počet pokusů
errorLog poslední chyba
preferred přednost před hromadnou rozesílkou
stopped zastaveno ručně nebo po vyčerpání pokusů — zpráva se nebude zkoušet dál
newsletter vazba na rozesílku, když jde o newsletter

Rozesílá cron /cron/system/email-queue/send — à minutu.

Bez běžícího cronu neodejde nic a nikdo se to nedozví

Fronta jen roste. Uživatel neuvidí chybu, protože z pohledu aplikace se odeslání povedlo — zpráva se přece uložila. Po nasazení proto ověřte, že úloha běží, a pak to ověřte znovu.

Newsletter je záměrně pomalý

Tisíce zpráv během vteřiny vypadají poštovním serverům jako spam. Dávkování je ochrana reputace domény, ne nedodělek.

Opakování, zastavení a retence#

EmailQueueManager::sendPending() bere dávku email.queue.batchSize (výchozí 50) zpráv, které nejsou odeslané ani zastavené a jejichž odstup už uplynul (next_attempt_at): nejdřív preferované, pak podle id.

Situace Co se zapíše
odesláno sent = 1, sendDatetime, errorLog se vymaže
chyba attempts + 1, errorLog, další pokus za 1 min, 5 min, 30 min, 2 h (RETRY_BACKOFF_MINUTES)
chyba a attempts dosáhl email.queue.maxAttempts (výchozí 5) navíc stopped = 1; za běh jedna notifikace EMAIL_QUEUE_FAILED ve zvonku, ne za každou zprávu

S výchozím nastavením zpráva přečká výpadek SMTP zhruba dvě a půl hodiny.

Cron /cron/system/email-queue/purge-old maže vyřízené zprávy (odeslané nebo zastavené) starší než email.queue.retentionDays (výchozí 30 dní, ?days=N ho přebije). Těla nesou osobní údaje a odkazy s tokeny. Čekající zprávy a řádky newsletteru zůstávají: unikátní index (newsletter, příjemce) brání tomu, aby tatáž adresa dostala týž newsletter podruhé.

Stav po odeslání nezapisuj přes entitu zprávy

Odeslání se ptá Api na nastavení SMTP a entita zprávy může být po odeslání odpojená (cizí hranice operace, abort() odmítnutého uložení). save() pak vrátí úspěch, ale sent nezapíše, a každý další běh cronu doručenou zprávu pošle znovu. Stav se proto zapisuje updateColumns() podle id a jeho Result se kontroluje: nezapsaný stav jde do logu mail jako EMAIL_QUEUE_STATE_NOT_RECORDED a do výsledku jako unrecorded.

Smazáním řádku newsletteru otevřeš druhé rozeslání

Kdo frontu čistí ručně, nechá řádky s vyplněným cms_mod_base_newsletters_newsletter_id. Bez nich unikátní index nic nehlídá a opakované spuštění rozesílky pošle newsletter stejným adresám znovu.

Šablony#

Šablony jsou v databázi, klíčované řetězcem, s textem pro každý jazyk zvlášť a zástupnými texty, které se nahradí z $values.

Klíč šablony se nepřejmenovává

Kód si šablonu hledá právě podle něj (findBy([new EqualFilter(['key'], [$emailKey])])). Po přejmenování sendEmail() šablonu nenajde, vrátí neúspěch — a protože se návratová hodnota často nekontroluje, zpráva prostě přestane chodit.

Chybějící překlad šablony znamená e-mail v jiném jazyce, ne chybu

Text se hledá pro $langId příjemce. Není-li, použije se fallback — příjemce dostane zprávu v hlavním jazyce.

Čísla v e-mailu: množství a lokalizace#

Množství ze skladu přijde z databáze jako '1.500'. V šabloně ho vypiš filtrem {$qty|quantity} („1,5“, celé číslo „5“ bez desetin) nebo ho předformátuj v manageru přes Quantity::format(); holé {$qty} ukáže 1.500, které anglický čtenář přečte jako tisíc pět set. Oddělovač i formát ceny určuje lokalizace, ve které render běží:

Příjemce Jazyk a lokalizace
uživatel nebo objednávka jazyk a lokalizace příjemce (objednávky, dokladu)
adresa z nastavení (např. stock.lowStock.email) výchozí jazyk a výchozí lokalizace webu; render obal PriceFormatContext::runWith($localization, $langId, fn () => …), aby \|quantity i \|priceFormat v šabloně použily stejnou lokalizaci jako čísla předformátovaná v PHP

Parametr s odkazem na akci bez přihlášení#

Má-li e-mail nést odkaz, který dělá jedinou věc bez přihlášení (označit jako přečtené), vlož ho jako zástupný text s celou adresou ('markReadLink' => $presenter->link('//:…')) a v šabloně ho obal podmínkou {if !empty($markReadLink)}…{/if}; starší záznamy odkaz nemají. Provozovatel šablony upravuje, proto novou část nepřepisuj, jen vlož blok, a SQL musí být idempotentní (LOCATE('markReadLink', body) = 0). Celý vzor je v Odkaz s klíčem bez přihlášení.

Měna do šablony i s překlady

Šablona priceFormat:$currency čte getTranslation($langId)->getUnitLabel(). emailManager->sendEmail() může běžet až po vyprázdnění identity mapy (cizí hranice operace, abort()), takže by se translations načetly líně až při renderu na odpojené měně a flush fronty by spadl na „A new entity was found through the relationship CurrencyText#parent". Měnu proto dej find($id, ['translations']).

Adresa webu v odkazech#

Odkaz, který opouští požadavek (e-mail, sitemapa, produktový feed, og:url), se nesmí skládat z hlavičky Host: podvržený Host by dostal cizí doménu do e-mailu jinému člověku a e-mail z cronu by závisel na tom, jak cron web volá. Adresu webu proto dává jediná služba App\Core\Http\SiteBaseUrl podle nastavení site.baseUrl (modul System, Nastavení webu → Odesílání e-mailů, viz Nastavení).

Stav Výsledek
site.baseUrl vyplněné platí vždy ono; tvar http(s)://host[:port][/cesta] bez ?, # a přihlašovacích údajů, koncové lomítko se při uložení odřízne
prázdné adresa požadavku (schéma, host, port, základní cesta), tedy chování jako dřív
prázdné a bez požadavku (CLI) ''; absolutize() vrátí vstup beze změny, nic nevyhodí

V presenteru nebo manageru s presenterem skládej odkazy pro e-mail přes PresenterContext::absoluteLink(':Front:Bazaar:Advert:detail', $args) (cíl bez úvodního //); hotovou absolutní adresu převeď PresenterContext::absolutize(). Router dál drží jazyk, slug i perzistentní parametry, služba jen vymění začátek adresy. Pojistkou pro všechny zprávy je SiteBaseUrl::rewriteRequestBase() nad vyrenderovaným tělem v SendEmailTrait. Starý vzor $presenter->link('//:…') v e-mailu už nepoužívej.

Stránky webu tím chráněné nejsou

site.baseUrl mění jen odkazy určené k odeslání nebo zveřejnění. Přesměrování na kanonický host ani kontrolu Host u běžných stránek nedělá; to řeší webový server (viz Roboti, robots.txt a sitemapy).

Postup: přidávám nový e-mail#

  1. Založ šablonu v administraci (Systém → E-maily) a dej jí klíč, který už neexistuje.
  2. Vyplň text pro všechny jazyky — ne jen pro hlavní.
  3. Zavolej sendEmail() z manageru, ne z presenteru:
$this->sendEmail(
    langId: $user->getLanguageId(),
    emailKey: 'order.confirmation',
    toEmailAddrr: $order->getEmail(),
    values: ['orderNumber' => $order->getNumber()],
);
  1. Volej to z completedSave / completedDelete, tedy až po commitu. Může-li manager běžet vnořeně v cizí operaci, obal odeslání do $this->service->afterCommit(fn () => …), ať e-mail odejde až po commitu nejvnější transakce (viz Životní cyklus manageru).
  2. Vyhodnoť výsledek — neúspěch znamená, že se zpráva ani nezařadila do fronty.
  3. Ověř na vývojovém stroji, kam zpráva doopravdy odešla (viz níž).

E-mail v afterSave odejde i tehdy, když se transakce vrátí zpět

afterSave běží uvnitř transakce. Zpráva o vzniklé objednávce se odešle, objednávka se rollbackne — a zákazník má v ruce potvrzení něčeho, co neexistuje. Nevratné efekty patří za commit, viz Životní cyklus manageru.

V cronu se pole NEfiltrují podle publika

Rozesílka běží mimo požadavek, takže #[FieldAccess] se neuplatní — a je to záměr, aktivační e-mail potřebuje activationKey. Znamená to ale, že co se dostane do těla zprávy, si musí ohlídat autor šablony a $values.

Adresy odběru newsletteru#

Adresu odběru zakládá a hlídá NewsletterAddressManager (Base). Aktivace se řídí nastavením 2/newsletter.address.approval.auto (Nastavení webu → Komentáře, hodnocení a newsletter; chybí-li řádek, adresa čeká na potvrzení e-mailem).

Situace Co se stane
nová adresa, bez automatického schválení neaktivní řádek, aktivační klíč, aktivační e-mail až v afterSave() po úspěšném insertu
přihlášení adresy, která už existuje (potvrzená i nepotvrzená) vždy USER_ALREADY_EXISTS; nepotvrzené se aktivace pošle znovu
úprava v administraci kolize e-mailu se hledá jen proti jiným řádkům; aktivace, klíč ani datum vložení se nemění, e-mail se neposílá
„Znovu poslat aktivaci“ (resendActivation()) jen neaktivní adresa; nový klíč, starý odkaz přestane platit; když se e-mail nepodaří odeslat, vrátí se předchozí klíč

Každý aktivační e-mail (nová adresa, opakované přihlášení i opětovné odeslání) počítá isActivationMailAllowed() per IP a per adresa (Kvóta zakládání obsahu); správce je z limitu vyňatý. Akce resendActivation má vlastní klíč :Api:Base:NewsletterAddress:resendActivation — bez řádku oprávnění ji RBAC tiše zamítne. Notifikace NEWSLETTER_ADDRESS_NEW vzniká v Api NewsletterAddressPresenter::internalActionSave(), jen u nové adresy a mimo administraci.

Veřejné přihlášení nesmí prozradit stav adresy

Potvrzená i nepotvrzená adresa vrací tentýž kód. Dva různé kódy by front přeložil na dvě různé hlášky a kdokoli by si ověřil, zda je cizí adresa přihlášená.

Kontrola duplicity musí vynechat upravovaný řádek

findBy(email) při úpravě najde i upravovanou adresu samu. Bez porovnání id by každé uložení existující adresy v administraci skončilo chybou a nic by se neuložilo.

Nouzová pojistka odesílání#

params.1.mail.blocked: true v config/Shared/local.neon zastaví veškerou poštu bez nasazení a nezávisle na databázi (třeba když formulář objeví robot). Kontroluje ji MailerTrait::initializeMailer(), kudy jde každé odeslání včetně cronu fronty, takže ji nastavení email.sending.mode v administraci nepřebije.

parameters:
    params:
        1:
            mail:
                blocked: true
                reason: 'robot na kontaktním formuláři'   # jde do logu
Kde Co se stane
okamžité odeslání místo maileru App\Core\Mail\NullMailer: zprávu zahodí, hlásí úspěch a zapíše řádek do log/mail-blocked.log (počet příjemců a otisky adres, ne adresy ani předmět)
cron fronty EmailQueueManager::sendPending() skončí hned (blocked: true), čekající zprávy zůstanou ve frontě
zařazení do fronty běží dál, zprávy se hromadí v cms_system_email_queue

Postup: zapínám a vypínám pojistku#

  1. Nastav blocked: true (a reason) v local.neon serveru.
  2. Smaž temp/cache/nette.configurator, jinak jede starý zkompilovaný kontejner.
  3. Ověř v log/mail-blocked.log, že se e-maily zahazují.
  4. Před vypnutím projdi cms_system_email_queue a smaž zprávy, které nemají odejít.
  5. Vrať blocked: false a znovu smaž temp/cache/nette.configurator.

Pojistka hlásí úspěch záměrně

Registrace, objednávka nebo obnova hesla stavějí na neúspěšném odeslání vlastní chybové hlášky a rollbacky. Kdyby pojistka vracela chybu, zablokovala by i tyhle operace. Proto se v aplikaci nic nehlásí a jediným důkazem je log/mail-blocked.log.

Po vypnutí odejde všechno, co se mezitím nahromadilo

Fronta během pojistky dál přijímá zprávy a cron je po vypnutí rozešle naráz. Když pojistku zapínáš kvůli robotovi, frontu před vypnutím vyčisti.

Vývojové prostředí#

Kopie ostré databáze obsahuje skutečné adresy

Vyvíjíte-li nad kopií ostrých dat (nebo dokonce nad ostrou databází), zkušební rozesílka může dorazit skutečným zákazníkům. Než něco odešlete, ověřte hodnotu 1/email.sending.mode a obsah fronty — a u hromadné rozesílky raději zkontrolujte i seznam příjemců.

Kam sáhnout#

Chci Kde
tři metody odesílání app/Core/Traits/Api/System/Models/Managers/SendEmailTrait.php
e-mail z frontu app/UI/Front/System/Models/Managers/EmailManager.php → app/UI/Api/System/Presenters/EmailPresenter.php
rozhodnutí fronta × okamžitě SendEmailTrait::sendEmailFromText() (email.sending.mode)
výběr maileru (SMTP odesílatele, výchozí SMTP, sendmail) a pojistku app/Core/Traits/Api/System/Models/Managers/MailerTrait.php
pojistku v konfiguraci config/Shared/email.neon → mail.blocked
entitu fronty app/Core/Base/Shared/System/Models/Entities/EmailQueue.php
rozesílací cron app/UI/Cron/System/Presenters/EmailQueuePresenter.php
správu šablon administrace → Systém → E-maily

Navazující kapitoly: Životní cyklus manageru · Cron presentery · Naplánované úlohy · Překlady

Nastavení cookies