Disassembler

Artificial intelligence is no match for natural stupidity.
22srpna2018

Jak správně posílat maily z PHP


„To je přece jasný, ne? Funkcí mail(). Takový blbý dotazy. Na to přece není potřeba psát celý článek.“ No... ne tak docela. Funkce mail() je sice vstupním bodem do procesu doručování, ale k tomu, aby byl mail doručitelný a čitelný druhou stranou, je většinou potřeba ještě pár kroků navíc. Jakožto administrátor virtuálního hostingu vržu zuby pokaždé, když mi někdo na server strčí webík posílající maily, ale už neřeší žádnou sanitizaci a konformitu. V lepším případě mail nedojde, v horším pak může být celý server označen jako původce spamu. Takže jak to dělat lépe a radostněji?

Kdysi dávno v jedné předaleké galaxii


Elektronická pošta je jedním z nejstarších užití počítačových sítí vůbec. RFC 821, které popisuje první standard pro Simple Mail Transfer Protocol, vzniklo v roce 1982 a rozhodně nebylo prvním návrhem, jak si posílat psaníčka po drátech. Existují i daleko starší implementace podobných principů, jako třeba RFC 196 - A Mail Box Protocol, který vznikl na samotném počátku 70. let minulého století a ze kterého SMTP vychází. SMTP zmiňuju hlavně proto, že se jedná o protokol, který se s pár obměnami pro přenos pošty používá i dnes, 36 let po jeho vzniku, a hned tak se používat nepřestane. To bohužel znamená, že si s sebou nese spoustu technologického dluhu, který je potřeba pomaloučku a polehoučku odbourávat. Myslím, že pro e-mailovou komunikaci a věci s ní přímo související existuje vůbec nejvíc standardů a specifikací ze všech možných odvětví, která jsou v kompetenci IETF. Namátkou

A to jsou jen aktuálně platné verze těch záležitostí, bez kterých se prakticky neobejdete. Připočtu-li méně používané protokoly, zastaralé verze standardů a méně zajímavé dodatky ke specifických funkcím u těch aktuálních, bude jich nejméně 20x tolik. Kapitolou samou pro sebe jsou pak překlady doménových názvů, povolené znaky a formáty e-mailových adres, které by bez problémů vydaly na samostatný článek (třeba takový o validaci doménových jmen regulárním výrazem). Samozřejmě nečekám, že každý, kdo chce poslat e-mail, bude všechny znát, ale je pokud programuji aplikaci, která s elektronickou poštou nějakým způsobem pracuje, bylo by dobré mít alespoň ponětí o tom, že nějaké standardy na tohle téma existují a ve chvíli, kdy to nejméně čekám, mohou vyskočit a kousnout mě do zadku.

Šunku nebo lančmít?


Oukej, takže teď už víme, že mail by měl nějak vypadat, aby vyhověl standardům. To je docela důležité i v boji se nevyžádanou poštou. Průměrný antispam většinou kouká na obsah zprávy a pokud vypadá jako spam, tak zprávu zahodí, označí nebo prostě provede jinou nakonfigurovanou akci. Opravdu dobrý antispam by ale zprávu, která vypadá jako spam, měl bez problémů propustit. To je docela divné tvrzení, viďte? Tak já jej upřesním. Opravdu dobrý antispam by měl propustit zprávu, která vypadá jako spam, ale ve skutečnosti spamem není.

Přestavte si situaci, kdy vám do firemního mailu přijde nějaká nevyžádaná reklama na levný a zajisté kvalitní medicínský produkt. A vy ten mail vezmete a přepošlete jej svému IT administrátorovi s upozorněním, že vám přišel spam, ať s tím něco udělá. Je to úplně ten samý mail, ale v prvním případě je nevyžádaný a nechcete, aby chodil, kdežto v druhém už je cílený a naopak chcete, aby zamýšlenému adresátovi dorazil. Ano, já vím, s opravdu kvalitním antispamem by se to nestalo a uživatelská identifikace toho co je a není spam se taky v ideálním případě dělá jinak, ale pracujme dál v této hypotetické rovině. Jak má tedy antispam poznat, která z těch dvou stejných zpráv je legitimní a která ne? No, právě z nějakých těch ostatních dat, která nejsou přímo v obsahu zprávy. Takže koukne třeba, odkud byl odeslán a porovná adresu s blacklisty nebo reputačními systémy. Zkontroluje SPF a DKIM záznamy, pokud existují. Koukne do různých Bayes filtrů a distribuovaných databází se vzorky, zda mail došel opakovaně na více adres. Koukne, zda nebyl podvržen odesílatel. A v neposlední řadě také koukne na hlavičky mailu a jejich validitu a konformitu. Tedy zjednodušeně řečeno – Chcete-li, aby vaše pošta nachytala co nejmenší spam skóre, musíte se ujistit, že odpovídá standardům a je odesílána z důvěryhodného serveru. Důvěryhodnost serveru jakožto aplikační vývojář většinou neovlivníte, takže se pojďme podívat, jaké náležitosti musí e-mail mít, aby měl co největší šanci na doručení.

Pozn.: Když v následujících bodech říkám musí, je povinný a tak podobně, tak tím myslím, že je taková konvence dána standardem. Samozřejmě se nemusí dodržet nic, ale pak taková zpráva taky nemusí přijít.

Pitevní zpráva


Když jsme si vyjasnili, co od zprávy očekáváme, pojďme se tedy podívat, co z PHP vypadne, použijeme-li v úvodu zavrhovanou funkci mail(). Zkusíme odeslat jednoduchý e-mail na adresu recipient@example.com s předmětem „Testovací zpráva“ a tělem zprávy „Testovací zpráva pro ověření funkce mailového systému.“.

<?php
$recipient = 'recipient@example.com';
$subject = 'Testovací zpráva';
$body = 'Testovací zpráva pro ověření funkce mailového systému.';
mail($recipient, $subject, $body);

Výsledná zpráva mi přišla takto:

Return-Path: <www-data@example.com>
Received: by example.com (Postfix, from userid 33)
    id 595CA521F07; Tue, 21 Aug 2018 12:52:57 +0200 (CEST)
To: recipient@example.com
Subject: Testovací zpráva
Message-Id: <20180821105257.595CA521F07@example.com>
Date: Tue, 21 Aug 2018 12:52:57 +0200 (CEST)
From: www-data <www-data@example.com>
X-Spam: Yes

TestovacĂ­ zprĂĄva pro ovÄ>Ĺ™enĂ­ funkce mailovĂŠho systĂŠmu.

V prvé řadě vidím, že to nějak nerozdýchalo kódování. Taky vidím, že mailový server, kterému PHP předalo zprávu k odeslání, automaticky doplnil Message-ID, Date a nějaké další méně zajímavé hlavičky sloužící pouze k identifikaci trasy mailu. Také mi sám doplnil hlavičku From, kterou nastavil na hodnotu systémového účtu, pod kterým na serveru PHP provozuji. Nakonec na posledním řádku vidím, že mi e-mail antispam označil jako spam. To právě proto, že tak hrubě nevyhovuje standardům. Naštěstí mám hodného administrátora serveru, který mi řekne, jaké příznaky zpráva nasbírala a proč. Antispam tvrdí, že zpráva má mimo jiné následující nedostatky:

Já ještě navíc vidím, že mnou předaná hlavička To není obalena špičatými závorkami, protože jsem si je tam sám nenapsal. To je naštěstí jen malý prohřešek. Zbytek hlaviček už je celkem OK, ale zpráva je tak doprasená, že to stačí k tomu, aby ji antispam označil a doručil rovnou do složky s nevyžádanou poštou. Kdybych ještě navíc odesílal z nějakého pochybně nastaveného serveru, klidně bych mohl nasbírat tolik bodů, aby antispam zprávu zahodil úplně.

Písmenková polívka


Takže to musíme nějak opravit. Buď se na můžeme na všechno vykašlat a sáhnout po nějaké hotové knihovně typu PHPMailer, která mimo to, že spoustu problémů vyřeší za nás, umožňuje odesílat maily přímo na server příjemce i bez lokálního odesílajícího agenta. Ale to zdaleka není taková zábava a nic byste se nedozvěděli, takže varianta druhá je použít ty správné kouzelné funkce a těch pár řádků trošku vyšperkovat. K tomu nám nejvíce pomůže PHP modul mbstring. Existuje od PHP 4, a pokud jej na svém serveru nebo hostingu náhodou nemáte, tak si jej doinstaluje nebo hodně hlasitě vydupejte, protože na češtinu a jiné jazyky s obskurními krucánky u písmenek je to nedocenitelná pomůcka. Stejně tak zastane kus práce i v případě, že potřebujete v PHP nějakým způsobem přežvýkávat emoji 🙈 🙉 🙊. Těmi kouzelnými funkcemi, kterými si usnadníte život jsou mb_send_mail(), kterážto je přímou náhradou funkce mail() s tím rozdílem, že si u předmětu a těla zprávy umí vyřešit kódování a správně jej pak indikovat i v hlavičkách. Druhou funkcí, kterou budete nejspíš potřebovat, je mb_encode_mimeheader() který zakóduje zbytek hlaviček, které do mb_send_mail() předáte.

No a jak to s tím kódováním vlastně je? Celou dobu tu melu o nějakém sedmibitovém. SMTP, milé děti, umí tři různé druhy kódování. To základní, které existuje od pradávna, je sedmibitové prostě proto, že umí obsáhnout pouze spodní polovinu ASCII tabulky, tedy US-ASCII rozsah (hex 00-7F). V počítačovém pravěku, kdy se o Unicode nikomu ani nezdálo, bylo celkem obvyklé, že ASCII tabulka byla rozdělena na spodní (hex 00-7F) a horní (hex 80 - FF) polovinu, přičemž ta spodní byla pro drtivou většinu kódování stejná a ta horní se lišila v závislosti na kódové stránce daného regionu nebo jazyka. Kdo si pamatuje mode con codepage select=852 v autoexec.bat v DOSu, tak to je přesně ono. No, a aby se mohl uživatel používající jednou kódovou stránkou bavit s uživatelem používajícím jinou, posílali si pouze ty společné znaky z dolní poloviny ASCII tabulky, které se vlezly do sedmibitového prostoru. O hodně později se SMTP naučilo 8BITMIME, čili kódování osmibitové. Začalo být tedy možné v mailech posílat diakritiku, resp. znaky z horní poloviny ASCII tabulky. Tím pádem bylo nutno i říct jakým kódováním dotyčný mluví, k čemuž se stejně jako u HTTP použije hlavička Content-Type, případně Charset. Bystrému čtenáři tedy nejspíš dojde, že absence této informace je důvodem k rozhození kódování, kterého jsme byli svědky v příkladu výše. Bez této informace záleží pouze na poštovním klientovi, které kódování se pokusí použít jako první. Teoreticky by nám tedy pomohlo deklarovat Content-Type, ale to by problém řešilo pouze v případě poštovních serverů neimplementujících ten třetí způsob kódování.

Tím třetím je SMTPUTF8. UTF-8 je kódování s proměnlivou délkou. Jeden znak může zabírat od jednoho do čtyř bajů, resp. kvůli způsobu, jakým je indikována délku znaku, efektivně mezi 7 a 21 bity. SMTPUTF8 umožňuje úplnou internacionalizaci a podporuje divokou směsku znaků od naší latinky, přes azbuku a alfabetu až po různé čínské, japonské, korejské, indické a všelijaké další znakové sady a to nejen v těle zprávy a adresách odesílatele a příjemce, ale i v hodnotách ostatních hlaviček. Problémem ale je, že se jedná o poměrně nový standard (RFC 6532, rok 2012), a proto ještě zdaleka není implementován všemi mailovými servery. V době psaní toho článku jej implementuje Postfix, Sendmail, Exim a možná pár dalších méně významných SMTP serverů. Z velkých poskytovatelů mailu jej umí Google a Microsoft. V Česku je to bída, protože Seznam ani Centrum jej v současnosti neumí. Nicméně pokud SMTP serveru implementujícímu SMTPUTF8 nasypete do hlavičky UTF-8, tak o něm prostě ví a snaží se použití UTF-8 vynutit, což se mu občas nepodaří a tak odesílání zprávy selže. Takže dokud nebude na světě krásně, v potocích nepoteče pivo, pečení holubi nebudou lítat do huby a poskytovatelé mailových služeb nebudou do puntíku dodržovat všechny standardy, je nutno naši krásnou češtinu a jiné ne-ASCII znaky kódovat ručně.

Sedm trpaslíků


Takže zpět na začátek. Sedmibitové kódování. V zásadě přichází v úvahu dvě metody, jak libovolná data nacpat do sedmibitového nebo ještě menšího prostoru. První metodou je Quoted-printable kódování. U něj se všechny znaky (až na pár výjimek), které jsou v US-ASCII, nechají tak, jak jsou, a ty zbylé se promění na jejich ordinální hexadecimální hodnoty (resp. hodnoty jejich bajtů) uvozenou rovnítkem. Tedy z řetězce „Testovací zpráva“ se stane Testovac=C3=AD=20zpr=C3=A1va. A aby server věděl, že je použito zrovna takové kódování, celá paráda se obalí identifikátorem, takže vypadne =?UTF-8?Q?Testovac=C3=AD=20zpr=C3=A1va?=. Podobným způsobem se escapují znaky i ve webových URL. Tam je místo znaku rovnítka znak procenta. Druhým často používaným způsobem je kódování base64. To už lidsky čitelné není ani omylem, protože se v něm jinak interpretují jednotlivé bitové skupiny. Princip je takový, že ze zprávy, kde jsou normálně znaky v osmibitových skupinách, se ukusuje po šesti bitech a hodnota této skupiny se použije jako index pro substituci z předem daného pole znaků ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/, kde, jak vidíte, jsou jen znaky z US-ASCII. Pokud v posledním kousku nevychází bity, vycpe se výsledný text rovnítky. Článek o base64 na Wikipedii to asi vysvětlí trochu lépe. Pro nás to znamená, že z řetězce „Testovací zpráva“ vypadne řetězec VGVzdG92YWPDrSB6cHLDoXZh a i s obalením pro MIME hlavičku pak =?UTF-8?B?VGVzdG92YWPDrSB6cHLDoXZh?=.

A přesně tohle pro nás udělají ony zmiňované funkce mb_encode_mimeheader() a mb_send_mail(). Ještě se sluší podoktnout, že SMTP standard požaduje kódování pouze u řetězců, které se jinak do 7bitového prostoru nevlezou. Tedy například hodnota hlavičky To: <recipient@example.com> by tedy měla být odeslaná bez jakéhokoliv dalšího překódování. Přehnané kódování vám tedy také může vyhrát nějaké punkové bodíky navíc. Funkce mb_encode_mimeheader() a mb_send_mail() jsou naštěstí tak chytré, že si umí ohlídat i tohle.

Ještě jednou a pořádně


Začnu nejprve jenom tím, že vyměním mail() za mb_send_mail().

<?php
$recipient = 'recipient@example.com';
$subject = 'Testovací zpráva';
$body = 'Testovací zpráva pro ověření funkce mailového systému.';
mb_send_mail($recipient, $subject, $body);

A světe div se, e-mail najednou vypadá takhle

Return-Path: <www-data@example.com>
Received: by example.com (Postfix, from userid 33)
    id 7617A52043E; Tue, 21 Aug 2018 14:41:40 +0200 (CEST)
To: recipient@example.com
Subject: =?UTF-8?B?VGVzdG92YWPDrSB6cHLDoXZh?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: BASE64
Message-Id: <20180821124140.7617A52043E@example.com>
Date: Tue, 21 Aug 2018 14:41:40 +0200 (CEST)
From: www-data <www-data@example.com>

VGVzdG92YWPDrSB6cHLDoXZhIHBybyBvdsSbxZllbsOtIGZ1bmtjZSBtYWlsb3bDqWhvIHN5c3TD
qW11Lg==

Nejen že mi mb_send_mail() krásně zakódoval předmět a tělo zprávy, ale ještě navíc mi jako mávnutím kouzelného proutku přidal všechny nezbytné hlavičky. Antispam je s takovým mailem plně spokojený a příznak o spamu tedy nepřidal. No a nebylo by to PHP, aby se jedna věc nedala dělat pěti způsoby, takže podobného chování vám můžou pomoci dosáhnout funkce quoted_printable_encode(), imap_8bit(), imap_binary(), iconv_mime_encode(), base64_encode() a možná ještě nějaké další. U všech ale čekejte nějaké pasti a nekonzistentní chování, ale na to jste v PHP už asi zvyklí.

Multipass


Tak si to trochu ztížíme. Přidáme nějaké hlavičky, přihodíme diakritiku i do nich a pošleme onen zmiňovaný multipart/alternative s plaintextem i HTML zároveň. Jak jsem již zmínil, PHP nemá žádné nativní funkce pro skládání těla mailu, takže je celou operaci nutno dělat tím nejprimitivnějším možným způsobem. Anebo opět použít nějakou třídu třetí strany, která to udělá za nás. Multipart funguje tak, že si uživatel zvolí nějaký řetězec, ať už náhodně nebo deterministicky, který se v těle mailu nebude jinak vyskytovat, aby nedošlo ke kolizi. Ten se deklaruje v hlavičce Content-Type jako boundary a v těle se pak prefixuje prázdným řádkem a dvěma pomlčkami. Za tím pak následují hlavičky deklarující kódování dané části, protože každá část může být kódována jinak. Na konci zprávy se boundary do těla vrzne ještě jednou a tentokrát se za něj místo hlaviček přidají dvě další pomlčky, aby bylo jasné, že tady mail končí.

Narážíme tu ale na obrovský problém. Funkce mb_send_mail() totiž očekává, že to, co se jí předá jako tělo, je první a poslední část zprávy, kterou má zakódovat celou od prvního do posledního znaku. My ale máme částí víc, a protože můžou mít různá kódování, musíme si je poskládat a zakódovat sami. Obrovským obloukem se tak dostávám na samotný začátek článku. V tomto případě totiž opravdu budeme muset použít holou funkci mail(), ale zúročíme veškeré dosud načerpané znalosti a připravíme a předáme jí vše, co je to potřeba k tomu, aby byla odeslaná zpráva v souladu se standardy.

<?php
$sender = mb_encode_mimeheader('Nejlepší firma').' <sender@example.cz>';
$recipient = mb_encode_mimeheader('Můj milý uživatel').' <recipient@example.com>';
$subject = mb_encode_mimeheader('Testovací zpráva', 'UTF-8', 'Q');
$boundary = 'XXX';

$headers = [
    'From' => $sender,
    'Reply-To' => $sender,
    'MIME-Version' => '1.0',
    'Content-Type' => 'multipart/alternative; boundary="'.$boundary.'"; charset=UTF-8'
];

$body_plain = 'Testovací zpráva pro ověření funkce mailového systému.';
$body_html = '<!DOCTYPE html><html><body><p>'.$body_plain.'</p></body></html>';

$body = [
    '--'.$boundary,
    'Content-Type: text/plain; charset=UTF-8',
    'Content-Transfer-Encoding: quoted-printable',
    '',
    quoted_printable_encode($body_plain),
    '',
    '--'.$boundary,
    'Content-Type: text/html; charset=UTF-8',
    'Content-Transfer-Encoding: BASE64',
    '',
    imap_binary($body_html),
    '',
    '--'.$boundary.'--'
];
$body = implode("\r\n", $body);

mail($recipient, $subject, $body, $headers);

Příchozí mail pak bude vypadat takto

Return-Path: <www-data@example.com>
Received: by example.com (Postfix, from userid 33)
    id 75224527596; Tue, 21 Aug 2018 22:36:14 +0200 (CEST)
To: =?UTF-8?B?TcWvaiBtaWzDvSB1xb5pdmF0ZWw=?= <recipient@example.com>
Subject: =?UTF-8?Q?Testovac=C3=AD=20zpr=C3=A1va?=
From: =?UTF-8?B?TmVqbGVwxaHDrSBmaXJtYQ==?= <sender@example.com>
Reply-To: =?UTF-8?B?TmVqbGVwxaHDrSBmaXJtYQ==?= <sender@example.com>
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="XXX"; charset=UTF-8
Message-Id: <20180821203614.75224527596@example.com>
Date: Tue, 21 Aug 2018 22:36:14 +0200 (CEST)

--XXX
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: quoted-printable

Testovac=C3=AD zpr=C3=A1va pro ov=C4=9B=C5=99en=C3=AD funkce mailov=C3=
=A9ho syst=C3=A9mu.

--XXX
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: BASE64

PCFET0NUWVBFIGh0bWw+PGh0bWw+PGJvZHk+PHA+VGVzdG92YWPDrSB6cHLD
oXZhIHBybyBvdsSbxZllbsOtIGZ1bmtjZSBtYWlsb3bDqWhvIHN5c3TDqW11
LjwvcD48L2JvZHk+PC9odG1sPg==

--XXX--

Kombinovat v jednom mailu Quoted-printable a base64 samozřejmě není ideální a i taková věc může rozsvítit nějakou méně důležitou antispamovou kontrolku. Já je v příkladu kombinuji pouze abych ilustroval všelijaké možné způsoby. Pokud si můžete vybrat, doporučuji sáhnout spíše po Quoted-printable. Je starší, takže by s ním neměl mít problémy ani žádný prehistorický systém. Sluší se ještě podotknout, že Quoted-printable i base64 předepisují maximální délku řádku včetně zalomení na 76 znaků. Nedodržení tohoto požadavku opět může vyústit ve výhružně vztyčený prst antispamového systému. Mnou použité funkce quoted_printable_encode() a imap_binary() zalamují řádky automaticky. Jiné, například base64_encode(), to nedělají.

TL;DR


Mám-li onen lán textu shrnout do několika málo vět, pak v závislosti na potřebách aplikace a programátorově entusiasmu doporučuji používat následující kombinace:

Jak se to dělá jinde


A na závěr malá ochutnávka toho, jak se maily posílají v méně zdraví škodlivých jazycích. Třeba můj oblíbený python posílá multipart maily takto

from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import Header
from email.utils import formatdate

msg = MIMEMultipart('alternative')
msg['Subject'] = 'Testovací zpráva'
msg['From'] = '{} <sender@example.com>'.format(Header('Nejlepší firma').encode())
msg['To'] = '{} <recipient@example.com>'.format(Header('Můj milý uživatel').encode())

body_plain = 'Testovací zpráva pro ověření funkce mailového systému.';
body_html = '<!DOCTYPE html><html><body><p>{}</p></body></html>'.format(body_plain);
msg.attach(MIMEText(body_plain, 'plain', 'UTF-8'))
msg.attach(MIMEText(body_html, 'html', 'UTF-8'))

Ošetření hlaviček a obsahu není nijak vázáno na odesílací funkci a nějaké veletoče s boundary tu nejsou potřeba. Všechno se děje pod pokličkou. V tuto chvíli máte prostě hotový mail a můžete si s ním dělat, co chcete. Balík email je součástí standardní knihovny a jeho dokumentace explicitně zmiňuje, že záměrně neimplementuje žádné možnosti odesílání, protože k tomu tu jsou jiné balíky. Pokud jej chcete poslat stejným způsobem, jako to dělá PHP, pak můžete pustit třeba

from subprocess import run

run(['/usr/sbin/sendmail', '-t'], input=msg.as_bytes())

A šmytec.