Testowe adresy email

Dzisiaj kilka słów o mailach w kontekście programowania (lub testowania) aplikacji internetowych. Najprościej rzecz ujmując, warto mieć pod ręką duuużo adresów email, co przydaje się przy np. testowaniu rejestracji albo mailingu. Skąd je wziąć?

W Internecie jest całe mnóstwo darmowych kont pocztowych, ale zakładanie dziesiątek kont, pamiętanie do nich haseł i logowanie się na każde z nich byłoby uciążliwe. Na szczęście Dobrzy Ludzie stworzyli różne usługi i narzędzia, które ten problem rozwiązują.

Tymczasowe konta

Najprostsze w użyciu są usługi w rodzaju niepodam.pl i migmail.pl. Wystarczy podać dowolny adres w domenie niepodam.pl albo migmail.pl, np. michal@niepodam.pl, a następnie wejść na taką stronę, wpisać wybrany login i już można czytać maile wysłane na ten adres.

migmail

Ponieważ takich adresów nie trzeba rejestrować można je równie dobrze generować automatycznie, co przydaje się np. przy testowaniu mailingu. Ja najczęściej korzystam z nich w środowiskach, które wysyłają „prawdziwe” maile np. UAT i produkcyjnym.

Wadą tych rozwiązań jest to, że wyświetlanie wiadomości w formacie HTML nie zawsze działa jak powinno, np. linki są obcięte albo nieklikalne, a elementy graficzne źle rozmieszczone. Dlatego słabo nadają się do testowania wyglądu maili.

Warto też pamiętać, że są to tymczasowe konta i wiadomości są usuwane automatycznie po kilku godzinach lub dniach.

GMail

Każde konto GMail ma dwie cechy, które przydają się przy mnożeniu aliasów:

  • Wszystkie występujące w nazwie użytkownika kropki są ignorowane.
  • Po nazwie użytkownika można postawić znak „plus” i wpisać dowolny tekst, który także zostanie zignorowany.

W efekcie dysponując kontem imienazwisko@gmail.com możemy generować unikalne adresy do woli, np. imie.nazwisko@gmail.com, i.m.i.e.nazwisko@gmail.com, imienazwisko+test1@gmail.com, imie.nazwisko+test2@gmail.com itd.

MailCatcher

MailCatcher to prosty demon SMTP, który całą korespondecję, która przez niego przechodzi pozwala przeglądać za pomocą prostego interfejsu webowego. Demon nie sprawdza poprawności adresów, więc można używać dowolnych loginów i domen, np. qwerty@aplikacja.dev. Najlepiej spisuje się w środowisku deweloperskim, które niekoniecznie ma wyjście do Internetu.

MailCatcher jest napisany w Ruby, więc o ile to środowisko uruchomieniowe tego języka nie jest zainstalowane na serwerze trzeba wykonać następujące polecenie:

aptitude install ruby ruby-dev libsqlite3-dev build-essential

Kiedy Ruby już działa to można zainstalować MailCatchera:

gem install mailcatcher

Uruchomienie MailCatchera z konsoli:

mailcatcher --foreground --http-ip=0.0.0.0

Domyślnie SMTP nasłuchuje na porcie 1025, a interfejs webowy jest dostępny na porcie 1080. Oczywiście takie niestandardowe parametry SMTP należy podać w konfiguracji PHP albo aplikacji.

W Symfony2 wystarczy dodać parametr port w konfiguracji SwiftMailera (w standardowej konfiguracji go nie ma). W pliku app/config/config.yml dodajemy:

swiftmailer:
    transport: "%mailer_transport%"
    host:      "%mailer_host%"
    username:  "%mailer_user%"
    password:  "%mailer_password%"
    port:      "%mailer_port%"

Zaś w app/config/parameters.yml:

parameters:
    # ...
    mailer_transport: smtp
    mailer_host: 127.0.0.1
    mailer_user: null
    mailer_password: null
    mailer_port: 1025
    # ...

Aktualizacja nginx do 1.8.0 i problem z PHP

Dzisiaj zespół dotdeb.org wypuścił nginxa 1.8.0 na Debiana, a ja niezwłocznie dokonałem aktualizacji z wersji 1.6.3. Początkową radość szybko zastąpiła panika, kiedy okazało się, że wszystkie strony oparte o PHP przestały działać, a konkretnie zaczęły zwracać pustą treść. W logach nginxa, PHP-FPM ani nigdzie indziej nie znalazłem błędów, w konfiguracji nginxa zmiany były tylko kosmetyczne, konfiguracja PHP w ogóle nie była ruszana, po prostu czeski film.

Rozwiązanie okazało się trywialne: w pliku /etc/nginx/fastcgi_params należy dodać linijkę:

fastcgi_param  SCRIPT_FILENAME    $document_root$fastcgi_script_name;

Potem już tylko przeładowanie konfiguracji nginxa i wszystko powinno działać.

Tytułem uzupełnienia dodam, że na właściwy trop trafiłem, kiedy spróbowałem połaczyć się z PHP-FPM bezpośrednio przez FastCGI. Wedle przepisu z tej strony w konsoli zapodałem:

SCRIPT_NAME=/index.php \
SCRIPT_FILENAME=/index.php \
REQUEST_METHOD=GET \
DOCUMENT_ROOT=/var/www/anime.com.pl/ \
cgi-fcgi -bind -connect /var/run/php5-fpm.sock

W odpowiedzi otrzymałem:

Primary script unknown
Status: 404 Not Found
Content-type: text/html

File not found.

Dalej już było z górki. Ciekawe tylko dlaczego to się nigdzie nie zalogowało?

Jako lekturę uzupełniającą polecam wpis z bloga Martina Fjordvalda, który tłumaczy czym różni się plik fastcgi_params od fastcgi.conf i skąd się ta różnica wzięła.

Wyłączanie filtrów w Doctrine2

Filtry w Doctrine to poniekąd pożyteczna funkcja, na filtrach jest oparte m.in miękkie kasowanie (ang. soft delete), które można podpiąć z paczki gedmo/doctrine-extensions. Filtry przeważnie są włączane na stałe w konfiguracji ORM-a w pliku config.yml, ale w razie potrzeby można je włączać i wyłączać w kodzie. Obiekt FilterCollection pobieramy z entity managera, a na nim można wykonać m.in. poniższe metody:

// sprawdzenie czy filtr jest zainstalowany
$em->getFilters()->has("soft-deleteable");

// sprawdzenie czy filtr jest włączony
$em->getFilters()->isEnabled("soft-deleteable");

// włączenie filtra
$em->getFilters()->enable("soft-deleteable");

// wyłączenie filtra
$em->getFilters()->disable("soft-deleteable");

Cała ta notka wzięła się stąd, że dłużej niż powinienem szukałem błędu w kodzie, a jego źródłem było właśnie miękkie kasowanie. Zapytanie, które powinno zwracać encję zwracało NULL, a taki przypadek nie był obsłużony w kodzie.

Tworzenie pliku CSV w pamięci

Nie tak dawno pisałem o funkcjach fgetcsv() i fputcsv() w kontekście nakładania filtrów na strumienie PHP. Również z pomocą strumieni można rozwiązać problem tworzenia plików CSV w pamięci. Wystarczy otworzyć taki niby-plik w pamięci:

$fh = fopen('php://temp', 'rw');

i już można do niego zapisywać dane przez fputcsv():

fputcsv($fh, ["Imię", "Nazwisko", "Login", "PESEL"]);

Na koniec wystarczy tylko przewinąć wskaźnik pozycji w pliku na początek i pobrać jego zawartość:

rewind($fh);
$csv = stream_get_contents($fh);
fclose($fh);

Checkbox kontra NOT NULL

W encji mamy pole legal1 typu boolean, które nie może być NULL-em.

    /**
     * @var boolean
     *
     * @ORM\Column(name="legal1", type="boolean", columnDefinition="TINYINT(1)")
     */
    private $legal1;

W formularzu pole jest prezentowane jako checkbox. Niby wszystko powinno działać, a jednak jeśli wyślemy formularz bez zaznaczania checkboxa to na encji nie jest wywoływana metoda setLegal1(), a w rezultacie Doctrine próbuje zapisać NULL jako wartość pola legal1, co oczywiście kończy się wyjątkiem.

Można kombinować na samej encji (np. zwracać z getLegal1() 0 jeśli wartość to NULL), ale chyba najbardziej eleganckim rozwiązaniem będzie zastosowanie transformera, który będzie tłumaczył wartości pomiędzy encją a formularzem. Prościutki transformer realizujący takie zadanie może wyglądać tak.

namespace MDurys\CommonBundle\Lib\DataTransformer;

use Symfony\Component\Form\DataTransformerInterface;

class BoolTransformer implements DataTransformerInterface
{
    public function transform($input)
    {
        return boolval($input);
    }

    public function reverseTransform($input)
    {
        return intval($input);
    }
}

W formularzu należy go zastosować jako model transfomer:

    $builder
        ->add(
            $builder->create('legal1', 'checkbox')->addModelTransformer(new BoolTransformer())
            )

Jego działanie polega na tym, że podczas tworzenia formularza zostanie wywołana metoda transform(), która wartość z bazy danych (0 lub 1) rzutuje na wartość boolowską. Z kolei podczas zapisywania formularza zostanie wywołana metoda reverseTransform(), która rzutuje wartość z formularza na liczbę całkowitą. Tym sposobem z NULL-a zrobi się 0.

Dekodowanie daty urodzenia i płci z PESEL-u

Wczoraj poprawiałem walidację numeru PESEL w aktualnym projekcie. PESEL zawiera informacje o płci i dacie urodzenia osoby, do wyciągnięcia tych danych napisałem sobie taką klasę.

class Pesel
{
    public static function extractSex($pesel)
    {
        return intval(substr($pesel, 9, 1)) % 2 == 0 ? 1 : 2;
    }

    public static function extractDate($pesel)
    {
        list($year, $month, $day) = sscanf($pesel, '%02s%02s%02s');
        switch (substr($month, 0, 1)) {
            case 2:
            case 3:
                $month -= 20;
                $year += 2000;
                break;
            case 4:
            case 5:
                $month -= 40;
                $year += 2100;
            case 6:
            case 7:
                $month -= 60;
                $year += 2200;
                break;
            case 8:
            case 9:
                $month -= 80;
                $year += 1800;
                break;
            default:
                $year += 1900;
                break;
        }
        return checkdate($month, $day, $year)
            ? new \DateTime("$year/$month/$day")
            : null;
    }
}

Metoda extractSex() zwraca 1 jeśli numer PESEL należy do kobiety lub 2 jeśli do mężczyzny. Metoda extractDate() zwraca obiekt DateTime z datą urodzenia z PESEL-u.

W klasie brakuje walidacji poprawności PESEL-u, ale w moim projekcie załatwia to osobny walidator.

Filtry strumieni w PHP

Ostatnio pracowałem nad wymianą plików w formacie CSV. PHP ma funkcje fputcsv() i fgetcsv(), które dbają o właściwe umieszczanie/dekodowanie cudzysłowów, więc zadanie jest trywialne. Jedyny problem polegał na tym, że zdalny system zapisywał i łykał pliki z kodowaniem Windows-1250 i znacznikami końca linii CRLF. Oczywiście można traktować iconvem tablice przekazywane do fputcsv() i zwracane przez fgetcsv(), przy zapisie można po każdym fputcsv() zapisywać znacznik LF, ale to rozwiązanie niezbyt eleganckie.

Na szczęście architektura PHP 5 jest elastyczna, funkcje wyjścia/wejścia operują na strumieniach, a do strumieni można dodawać filtry. Powyższy problem można rozwiążać dodając do strumienia filtr, który dokona konwersji kodowania i znaczników końca linii. Wystarczy do tego malutka klasa:

namespace MDurys\DataBundle\StreamFilter;

class CP1250CRLFFilter extends \php_user_filter
{
    public function filter($in, $out, &$consumed, $closing)
    {
        while ($bucket = stream_bucket_make_writeable($in)) {
            $bucket->data = str_replace("\n", "\r\n", iconv('UTF-8', 'CP1250', $bucket->data));
            $consumed += $bucket->datalen;
            stream_bucket_append($out, $bucket);
        }
        return PSFS_PASS_ON;
    }
}

Plik otwieramy jak zwykle:

if (null === ($fh = fopen($path, 'w'))) {
        throw new FileException($path);
}

Potem wystarczy zarejestrować filtr i dodać go do uchwytu naszego pliku:

stream_filter_register('windows', '\MDurys\DataBundle\StreamFilter\CP1250CRLFFilter');
stream_filter_append($fh, 'windows');

Od tej pory wszystko co zapiszemy do pliku zostanie skonwertowane z UTF-8 na CP1250, a znacznki końca linii zamienione na CRLF.

Symfony2, Twig i SwiftMailer czyli rzecz o obrazkach w mailu

Ongiś pisanie maili w HTML-u było naruszeniem netykiety, ale obecnie zdecydowana większość odbiorców jak i nadawców woli otrzymywać maile w HTML-u. Generowanie i wysyłanie takowych w aplikacji opartej na Symfony2 jest banalnie proste dzięki Twigowi i Swift Mailerowi. Nieco mniej przyjemnie zaczyna się robić, kiedy w treści maili ma pojawić się grafika.

Możliwości są dwie i każda rodzi pewne problemy:

  1. Linkowanie do plików na zdalnym serwerze. Niestety funkcja url() w Twigu akceptuje tylko ścieżki zdefiniowane w routingu, nie można nią linkować do grafik. Z kolei znacznik image obsługiwany przez Assetica domyślnie generuje relatywne URL-e, a poza tym jest trochę nieporęczny w użyciu.
  2. Osadzenie plików w wiadomości. Tu problem natury programistycznej polega na tym, że żeby osadzić grafikę w wiadomości trzeba skorzystać z metody Swift_Message::embed(), do której nie mamy dostępu z poziomu Twiga.

Wszystkie powyższe problemy można rozwiązać tworząc rozszerzenie do Twiga.

namespace MDurys\MailBundle\Twig;

use Symfony\Component\DependencyInjection\ContainerInterface;

class MailExtension extends \Twig_Extension
{
    private $kernel;

    private $host;

    private $imageCache;

    private $messageCache;

    public function __construct(ContainerInterface $container)
    {
        $context = $container->get('router')->getContext();
        $this->host = $context->getScheme() . '://' . $context->getHost();
        $this->kernel = $container->get('kernel');
    }

    public function getFunctions()
    {
        return array(
            new \Twig_SimpleFunction('mail_embed', array($this, 'mailEmbed')),
            new \Twig_SimpleFunction('mail_url', array($this, 'mailUrl')),
        );
    }

    public function mailEmbed(\Swift_Message $message, $file)
    {
        if (!isset($this->messageCache[$message->getId()][$file]))
        {
            if (!isset($this->imageCache[$file]))
            {
                $this->imageCache[$file] = \Swift_Image::fromPath($this->kernel->locateResource($file));
            }
            $this->messageCache[$message->getId()][$file] = $message->embed($this->imageCache[$file]);
        }
        return $this->messageCache[$message->getId()][$file];
    }

    public function mailUrl($file)
    {
        return $this->host . $file;
    }

    public function getName()
    {
        return 'mdurys_mail_extension';
    }
}

Rozszerzenie udostępnia dwie funkcje Twiga: mail_embed() i mail_url(), które odpowiednio umożliwiają osadzenie obrazka w wiadomości i wygenerowanie abosultnego URL-a do zasobu. Osadzane obrazki są cacheowane, więc jeśli dany obrazek jest wykorzystywany kilka razy w danej wiadomości to zostanie osadzony tylko raz, a jeśli obrazek zostanie osadzony w kilku wiadomościach to proces konwersji na obiekt Swift_Image zostanie wykonany tylko raz.

Aby móc z tego rozszerzenia korzystać należy je uaktywnić w services.yml bundla. Ważne jest, żeby otagować usługę jako „twig.extension”.

services:
  mdurys.twig.mail_extension:
    class: MDurys\MailBundle\Twig\MailExtension
    arguments: [ "@service_container" ]
    tags:
      - { name: twig.extension }

Jeśli chcemy osadzić obrazek w wiadomości to podczas renderowania szablonu trzeba przekazać obiekt wiadomości.

$message = \Swift_Message::newInstance();
$htmlPart = $this->renderView(
    'MailBundle:Test:email.html.twig',
    array('message' => $message)
    );
$message
    ->setFrom('mail@example.com')
    ->setTo('test@fexample.com')
    ->setSubject('Mail z obrazkami')
    ->setBody($htmlPart, 'text/html');
$this->get('mailer')->send($message);

W szablonie Twiga wystarczy wywołać funkcję mail_embed() przekazując jej jako parametry obiekt wiadomości i lokalizację pliku.

<img src="{{ mail_embed(message, '@MDurysMailBundle/Resources/public/images/smiley.gif') }}" width="16" height="16" alt=":-)" />

Linkowanie do zewnętrznych zasobów jest prostsze i sprowadza się do wywołania funkcji mail_url() w szablonie:

<img src="{{ mail_url('/bundles/mdurysmail/images/big_logo.jpg') }}" width="600" height="240" alt="Big Logo" />

Klucz obcy jako klucz główny w Doctrine2

Jest to jak najbardziej możliwe i bardzo ładnie opisane w dokumentacji Doctrine2. Przykład poniżej.

namespace Acme\Bundle\ShopBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="customer_preferences")
 */
class CustomerPreferences
{
    /**
     * @ORM\Id
     * @ORM\OneToOne(targetEntity="Acme\Bundle\ShopBundle\Entity\Customer")
     * @ORM\JoinColumn(name="customer_id", referencedColumnName="id")
     */
    private $customer;

    // ...
}

W tabeli wygenerowanej z tej encji pole customer_id będzie zarówno kluczem obcym wskazującym na pole id w tabeli customer jak i kluczem głównym.

Z jednej strony wydaje się to oczywiste i intuicyjne, ale parę razy przekonałem się, że rozwiązania w Doctrine2 nie zawsze są oczywiste i intuicyjne. Na szczęście akurat w tym wypadku są.

Squid zmienia WSDL?

Z używaniem SOAP-a przez proxy w PHP bywają problemy, o czym już pisałem. Dzisiaj w podobnej sytuacji natknąłem się na inną zagwostkę. Podczas pisania integracji z usługą eraty.pl prosty kod z instrukcji:

$client = new \SoapClient('https://www.eraty.pl:8443/eRatyWSService/eRatyWS?wsdl');
$identyfikacja['login'] = 'ERatyTest';
$identyfikacja['haslo'] = 'ERatyTest!1';
$identyfikacja['numerSklepu'] = 13010005;
$identyfikacja['typProduktu'] = 0;
$identyfikacja['wariantSklepu'] = 1;
try
{
	$result = $client->pobierzLinieKredytowe($identyfikacja);
}
catch (\Exception $e)
{
	echo $e->faultstring;
}

rzucał mi wyjątkiem „Could not connect to host”. Oczywiście zarówno firewall i proxy zezwałały na dostęp do eraty.pl, a ten sam kod uruchomiony na innym serwerze działał bez zająknięcia. Nad znalezieniem rozwiązania trochę musiałem posiedzieć, aż w końcu w stack trace’ie zauważyłem, że metoda SoapClient::__doRequest() jako drugi parametr dostaje http://www.eraty.pl:8080/eRatyWSService/eRatyWS (hint: numer portu). Nie mam pojęcia skąd to się bierze, zakładam, że to coś linii PHP-Squid, ale z tą wiedzą rozwiązanie było proste. Wystarczyło wymusić poprawną lokalizację usługi przy tworzeniu obiektu SoapClient:

$client = new \SoapClient('https://www.eraty.pl:8443/eRatyWSService/eRatyWS?wsdl',
	array('location' => 'https://www.eraty.pl:8443/eRatyWSService/eRatyWS')
	);