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.

Jeździłem Bentleyem… wśród odkurzaczy

Gościłem prezentację odkurzacza urządzenia do filtracji powietrza i sprzątania Rainbow. Nigdy nie byłem na prezentacji kosmicznych garnków ani cudownych kołder, więc byłem ciekawy jak to wygląda. Spodziewałem się, że będzie to trwać godzinę do półtorej. Tymczasem pranie/odkurzanie mózgu trwało… trzy godziny, a prowadzący powiedział, że to krótko, bo zazwyczaj trwa ok. pięciu. W tym czasie dowiedziałem się, że mokry kurz nie lata, że w kanapie mam dwa kilo złuszczonego naskórka, że śpiąc krócej wstaję świeższy niż po długim śnie, bo wdycham mniej roztoczy, a kilka wilgotnych szmatek symulujących moje płuca zostało zatkanych syfem wyrzucanym przez zwykłe odkurzacze. Efektem ubocznym prezentacji było odkurzenie połowy salonu i dwóch psich dywaników.

 Rainbow E2

Żarty na bok. Samo urządzenie sprawia dobre wrażenie. Przede wszystkim znakomicie robi to co ma robić odkurzacz czyli ssie jak filmografia Milli Jovovich. Przy tym jest cichszy niż mój w miarę nowy Samsung, żre mniej prądu, a wylanie wody z pojemnika jest łatwiejsze i bardziej higieniczne niż opróżnienie worka. Jest solidnie zbudowany, co daje nadzieję, że faktycznie będzie działać te 20 lat. Jednocześnie ma podobne gabaryty co typowy odkurzacz i jest od niego niewiele cięższy. Ponadto podoba mi się, że jest produkowany w USA, a nie Chinach. A to wszystko za jedyne 9000 zł.

No właśnie, taka cena czyni Rainbowa Bentleyem wśród odkurzaczy. Super samochód, super osiągi, ale płaci się przede wszystkim za elitarność i prestiż marki, który spływa na nabywcę. W wypadku samochodu to działa. Snobować się odkurzaczem? Nie.

PS. Jakby ktoś chciał sprawdzić to na własnej skórze to chętnie polecę was akwizytorowi, a ja dostanę nawilżacz i jonizator powietrza RainMate warty 700 zł!

Żegnaj T-Mobile

Przedwczoraj pożegnałem się z T-Mobile. Z jednej strony nic wielkiego, telefonia komórkowa jest teraz usługą tak podstawową i powszechną jak higiena jamy ustnej, a nie rozwodziłbym się nad zmianą pasty do zębów. Z drugiej strony abonament w Erze, a później T-Mobile, miałem od ponad 11 lat. To dłużej niż używam jakiejkolwiek marki pasty do zebów, to nawet dłużej niż jestem z moją dziewyczną, a obecnie żoną. ;-) Zapewne dlatego tak po ludzku trochę mi żal, że T-Mobile rozstał się ze mną bez mrugnięcia okiem, jakby te 11 lat z hakiem nic dla niego nie znaczyło. Ani przed końcem umowy ani po złożeniu wniosku o przeniesienie numeru nie skontaktował się ze mną i nie zaproponował przedłużenia umowy, o próbie przebicie oferty konkurencji nawet nie wspominając. Nie dostałem nawet SMS-a „szkoda, że odszedłeś, ale mam nadzieję, że wrócisz”. Żal mi, bo wydaje mi się, że byłem dobrym klientem, od ponad 11 lat płaciłem abonament, niby niezbyt wysoki, ale w dobie malejącej liczby abonentów i ostrej konkurencji w segmencie usług przedpłaconych ponoć każdy regularnie płacący łoś jest cenny.

Jednocześnie odczuwam ulgę, bo już od dłuższego czasu związek z T-Mobile mi się nie układał. Przy każdym przedłużeniu czułem się jak klient drugiej kategorii, bo pomimo wieloletniego stażu (a właściwie przez ten wieloletni staż) dawano mi gorsze warunki niż nowym klientom (tak, wiem, że tak działa dużo firm). Nie mogliśmy znaleźć porozumienia co do wzajemnych oczekiwań – ja miałem niewielkie potrzeby i chciałem niewiele płacić, T-Mobile konsekwentnie kasował tanie taryfy i usilnie chciał mi wcisnąć usługi, których nie potrzebuję i słono za nie kasować. W związku z tym szczególnie nie spodobało mi się skasowanie programu Era Premia, dzięki któremu „za darmo” mogłem nieco dopasować taryfę do moich potrzeb. Irytowało mnie poczucie humoru T-Mobile przejawiające się przykładowo w taryfie multimedialnej dla smartfonów z pakietem 100 MB transferu. Dużą kroplą w czarze goryczy była żenująca reklama „najlepsi przechodzą do T-Mobile”.

Zatem żegnaj T-Mobile, tak chyba będzie lepiej dla nas obojga.

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.