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ą.

Antyspam w O2.pl lubi linki

Tworzenie i wysyłanie maili z aplikacji tak, by nie wpadały do spamu to spory kawałek wiedzy. Być może kiedyś opiszę moje doświadczenia w tym temacie, dzisiaj skupię się na jednej ciekawostce związanej z omijaniem filtra antyspamowego w poczcie O2.pl.

Problem na pierwszy rzut oka nietypowy: niektóre z maili wysyłanych na konta O2.pl dochodzą, inne zaś wpadają do spamu. Oczywiście maile wysyłane w z jednej aplikacji, wysyłane w ten sam sposób i z grubsza w tym samym czasie, jedyną różnicą jest treść. Niestety nagłówki w O2.pl nie zawierają informacji co dokładnie powoduje zakwalifikowanie wiadomości jako spam. Na wyczucie próbowałem parafrazować i przeredagować treść problematycznych maili, ale to nic nie dało. W końcu zauważyłem, że odrzucane maile nie zawierają żadnych linków podczas gdy te poprawnie dostarczane przynajmniej jeden. Na próbę dodałem byle jaki link i jak za dotknięciem magicznej różdżki maile zaczęły lądować w skrzynce „Odebrane”.

Na moje wyczucie powinno być odwrotnie, to maile z linkami są bardziej podejrzane. Zgaduję, że może filtr antyspamowy O2.pl zakłąda, że wiadomość powinna zawierać link do wypisania się z listy, ale to byłoby dość dziwne założenie. Tak czy owak dodanie linka pomaga.

Przydatne gotowce ffmpeg

Mój aparat nagrywa filmy korzystając z kodeka MJPEG dla obrazu i PCM dla dźwięku, co gwarantuje dobrą jakość, ale kosztem niskiego stopnia kompresji. W większości wypadków nie potrzebuję takiej jakości, więc by zaoszczędzić miejsce na dysku konwertuję filmy do formatu MP4 z obrazem w H264 i dźwiękiem AAC. By nie musieć konwertować każdego pliku z osobna używam polecenia find w połączeniu z ffmpeg i taki duet konwertuje wszystkie filmy we wskazanym katalogu:

find /media/foto/wakacje_2013/ -type f -iname *.AVI -exec ffmpeg -i {} -c:v libx264 -preset slow -crf 22 -acodec libfaac -ac 2 -ar 44100 -ab 128k {}.mp4 \;

Jeśli dodatkowo zachodzi potrzeba obrotu obrazu można posłużyć się filtrem transpose z wartością 1 powodującym obrót o 90 stopni:

find /media/foto/chorwacja_2008/ -type f -iname *.AVI -exec ffmpeg -i {} -vf "transpose=1" -c:v libx264 -preset slow -crf 22 -acodec libfaac -ac 2 -ar 44100 -ab 128k {}.mp4 \;

Jak zwykle w wypadku polecenia find tylny ukośnik i średnik na końcu linii są niezbędne.

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')
	);

Małe a cieszy: sp_executesql

Aplikacja, nad którą obecnie pracuję, dostaje od użytkowników zapytania SQL, które wykonuje na wielu bazach danych MS SQL. Zapytania mogą być najróżniejszego typu, od prostych SELECTów po złożone skrypty w Transact SQL-u. Wykonywanie takich złożonych zapytań przez PDO i odczytywanie wyników mogłoby być kłoptliwe, analiza zapytań i rozbijanie ich na pojedyncze kwerendy jeszcze bardziej. Bólu głowy mozna sobie oszczędzić w bardzo prosty sposób – z pomoca przychodzi sp_executesql. Nasze złożone zapytania możemy wykonać poprzez tę funkcję przerzucając rozwiązanie wspomnianych problemów na bazę danych.

$statement = $dbh->prepare("EXEC sp_executesql N" . $dbh->quote($query));
$statement->execute($params);
$rows = $statement->fetchAll();

Wywoływanie funkcji z nieznaną liczbą parametrów

Niektóre funkcje PHP mogą akceptować zmienną liczbę parametrów. To wygodne rozwiązanie, ale co zrobić jeśli nie mamy z góry ustalonej liczby parametrów, a funkcja nie ma swojego odpowiednika przyjmującego parametry w tablicy?

Załóżmy, że chcemy wyciągnąć wspólne wartości z kilku tablic. Można to zrobić używając funkcji array_intersect(). Jeżeli liczba tablic na wejściu nie jest znana możemy przetwarzanie je w pętli. Dużo prościej będzie jednak posłużyć się funkcją call_user_func_array().

$result = call_user_func_array('array_intersect', $arrayOfArrays);

W zmiennej $arrayOfArrays oczywiście znajdują się tablice, z których chcemy wyciągnąć wspólne wartości.

Wadą call_user_func_array() jest narzut czasowy, wywoływanie funkcji w ten sposób jest wyraźnie wolniejsze niż bezpośrednio. W krytycznej pętli chyba warto by się pokusić o switcha, który wywołuje funkcję z odpowiednią ilością argumentów.

PDO dblib a parametr charset

Dzisiaj zmagałem się z kodowaniem znaków w bazach danych Microsoft SQL. Sytuacja wyglądała następująco: PHP na Linuksie, aplikacja w UTF-8, na innym serwerze kilkadziesiąt baz danych Microsoft SQL, a żeby było zabawniej bazy używały kilku kodowań znaków.

Do baz łączyłem się przez PDO ze sterownikiem dblib. Pomysł z konwertowaniem kodowania w aplikacji szybko odrzuciłem, zamiast tego zainteresowałem się parametrem charset. W dokumentacji nie jest jasno opisany, ale łatwo można się domyślić, że informuje sterownik o oczekiwanym kodowaniu.

$dbh = new \PDO('dblib:charset=UTF-8;host=10.12.34.56;dbname=nazwa_bazy', $dbUser, $dbPassword);

Niestety to nie działało. Rozwiązaniem okazała się zmiana konfiguracji FreeTDS w pliku /etc/freetds/freetds.conf.

tds version = 7.0

Ta zmiana wymusza połaczenia z użyciem wersji 7.0 protokołu. Wedle dokumentacji dopiero w tej wersji działa parametr client charset.

Serwer na Debianie: nginx, PHP i bazy danych

Przedstawiam mój przepis szybkie postawienie serwera, na którym działa nginx, PHP i bazy danych. Ja z powodzeniem używam takiej konfiguracji na maszynie deweloperskiej, ale można jej też użyć jako punktu wyjścia do konfiguracji serwera produkcyjnego, jednak w tym wypadku radzę uważnie przeczytać zastrzeżenia na końcu.

W przepisie używam Debiana Squeeze, ale po dodaniu sudo przed większością poleceń będzie go można zastosować do Ubuntu. ;-)

Opisywana konfiguracja spełnia następujące założenia:

  • Na serwerze mają działać nginx, PHP, MySQL, PostgreSQL, MongoDB i memcached.
  • Oprogramowanie powinno być w możliwie aktualnych stabilnych wersjach.
  • Skrypty PHP mają działać z prawami użytkownika.
  • Serwer powinien mieć możliwość uruchamiania skryptów PHP zabezpieczonych ionCube’em.

Czytaj dalej „Serwer na Debianie: nginx, PHP i bazy danych”

Błąd w ADOdb CacheFlush()

Przy okazji przeprowadzki anime.com.pl na nowy serwer zaktualizowałem wykorzystywane biblioteki, w tym ADOdb. W trakcie testów odkryłem, że coś jest nie tak z obsługą cache, a konkretnie czyszczenie zapamiętanych wyników przez CacheFlush() nie działało. Po analizie kodu ADOdb odkryłem, że pierwszą rzeczą, którą robi CacheFlush() jest sprawdzenie czy $ADODB_CACHE jest ustawione:

global $ADODB_CACHE_DIR, $ADODB_CACHE;	
if (empty($ADODB_CACHE)) return false;

Problem polega na tym, że $ADODB_CACHE jest inicjalizowane tylko w metodzie CacheExecute() czyli innymi słowy, żeby skorzystać z CacheFlush() trzeba najpierw wywołać CacheExecute(). Moim zdaniem to błąd (wcześniej chyba tak nie było), zgłosiłem go i mam nadzieję, że zostanie poprawiony. W międzyczasie pozostaje poprawienie ADOdb na własną rękę lub wywoływanie CacheExecute() przed CacheFlush().