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#
- Založ šablonu v administraci (Systém → E-maily) a dej jí klíč, který už neexistuje.
- Vyplň text pro všechny jazyky — ne jen pro hlavní.
- Zavolej
sendEmail()z manageru, ne z presenteru:
$this->sendEmail(
langId: $user->getLanguageId(),
emailKey: 'order.confirmation',
toEmailAddrr: $order->getEmail(),
values: ['orderNumber' => $order->getNumber()],
);
- 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). - Vyhodnoť výsledek — neúspěch znamená, že se zpráva ani nezařadila do fronty.
- 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.
| 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#
- Nastav
blocked: true(areason) vlocal.neonserveru. - Smaž
temp/cache/nette.configurator, jinak jede starý zkompilovaný kontejner. - Ověř v
log/mail-blocked.log, že se e-maily zahazují. - Před vypnutím projdi
cms_system_email_queuea smaž zprávy, které nemají odejít. - Vrať
blocked: falsea 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