четверг, 23 октября 2008 г.

Email рассылка средствами Swift

Email рассылка достаточно часто возникающая, перед разработчиком, задача. В простейшем случае это рассылка новостей сайта. На сайте symfony в The symfony Cookbook для отправки электронной почты предлагается использовать Swift Mailer. И действительно Swift Mailer - это мощная библиотека решающая практически все задачи связанные с отправкой электронных писем. Именно с ее помощью мы и создадим нашу email рассылку.

Установка Swift Mailer

Для начала надо установить Swift Mailer. Если проект находится под контролем Subversion, тогда добавим свойство svn:externals директории lib/vendor, если такой директории еще нет, то можно ее создать.

    $ mkdir -p lib/vendor
    $ svn propedit svn:externals lib/vendor
    
добавим следующую строку

    swift http://swiftmailer.svn.sourceforge.net/svnroot/swiftmailer/tags/php5/3.3.3/lib/
    
и обновим

    $ svn update
    
Если проект не использует Subversion, то просто разместим Swift Mailer в директории lib/vendor, для этого можно выполнить следующие команды:

    $ mkdir -p lib/vendor
    $ svn co http://swiftmailer.svn.sourceforge.net/svnroot/swiftmailer/tags/php5/3.3.3/lib/ lib/vendor/swift
    
На этом установка завершена.

Создание моделей

Теперь определим модели данных, которые нам потребуются. В простейшем случае - это подписчик (Subscriber) и сообщение (Message). Модели могут быть примерно следующими:

    --- config/doctrine/subscribe.yml
    Subscriber:
      columns:
        first_name: string(100)
        middle_name: string(100)
        last_name: string(100)
        email: string(200)
        email_key: string(40)

    Message:
      columns:
        subject: string(255)
        body: string
        sended: timestamp
    
Несколько комментариев. В модели Subscriber пояснить пожалуй стоит только поле email_key. Это хеш, сгенерированный для каждого подписчика и используемый для возможности отписаться от рассыкли. В моделе Message поле sended содержит отметку времени об отправке письма, если это поле null значит письмо еще не было отправлено и его следует разослать подписчикам. Предназначение остальных полей кажется вполне понятным исходя из их названий. Вопрос о создании интерфейса управления сообщениями, подписчиками и т.д. оставим за кадром.

Команда рассылки писем

Для рассылки писем создадим команду (task), для этого воспользуемся командой:

    $ ./symfony generate:task mailer:send 
    
Это создаст команду mailer:send определенную в файле lib/task/mailerSendTask.class.php. Метод configure этой команды может выглядеть примерно так:

    protected function configure()
    {
        $this->namespace        = 'mailer';
        $this->name             = 'send';
        $this->briefDescription = 'Рассылает письма';
        $this->detailedDescription = <<<EOF
[mailer:send|INFO] Отправляет письмо подписчикам.
Используется так:

  [./symfony mailer:send appliaction --env=environment|INFO]

Простой пример:

  [./symfony mailer:send frontend --env=prod|INFO]
EOF;
        $this->addArgument('application', sfCommandArgument::REQUIRED, 'Приложение');
        $this->addOption('env', null, sfCommandOption::PARAMETER_REQUIRED, 'Окружение', 'dev');
    }
    
Добавление аргумента application в данном случае обязательно, так как мы будем использовать базу данных. В дальнейшем команду надо будет запускать примерно так:

    $ ./symfony mailer:send frontend
    

Подготовка к рассылке email

Для начала необходимо получить письмо, которое следует разослать подписчикам. Для этого определим метод getWaitingMessage() в классе MessageTable, он может выглядеть примерно так:

    # lib/model/doctrine/MessageTable.class.php
    public function getWaitingMessage()
    {
        return $this->createQuery('m')
                ->where('m.sended IS NULL')
                ->limit(1)
                ->execute()
                ->getFirst();
    }
    
Также необходимо получить подписчиков, но для этого нам пока достаточно простого:

    Doctrine::getTable('Subscriber')->findAll();
    

Подготовка сообщения для email рассылки

Прежде, чем отправлять письмо, его необходимо подготовить. Например, если мы создаем сообщение через wysiwyg редактор, то нам как минимум необходимо вложить все изображения и изменить на них ссылки, а так же надо проставить email_key в ссылку по которой можно отписаться от рассылки. Для этого определим метод getMessage() в классе нашей команды mailerSendTask, в простейшем случае он может выглядеть примерно так:

    protected $message;
    public function getMessage()
    {
        // получаем сообщение для отправки
        $this->message = Doctrine::getTable('Message')->getWaitingMessage();
        if (!$message)
        {
            throw new sfException('Не найдено сообщений для отправки');
        }

        // создаем email сообщение используя Swift
        $mimeMessage = new Swift_Message($this->message['subject']);
        $mimeMessage->setCharset('UTF-8');

        // находим все изображения в сообщении.
        $images = array();
        if (preg_match_all('/<img[^>]*?src=(?:\'|")(.*?)(?:\'|")/umsi', $this->message['body'], $matches))
        {
            foreach($matches[1] as $match)
            {
                $images[$match] = new Swift_Message_Image
                (
                    new Swift_File($webDir . DIRECTORY_SEPARATOR . $match)
                );
            }
        }

        // приаттачиваем найденные изображения и меняем ссылки в сообщении
        if (!empty($images))
        {
            foreach($images as $src => $image)
            {
                $attach = $mimeMessage->attach($image);
                $this->message['body'] = str_replace($src, $attach, $this->message['body']);
            }
        }

        // наконец прикрепляем собственно сообщение.
        $mimeMessage->attach
        (
            new Swift_Message_Part
            (
                $this->message['body'], 
                'text/html', 
                'base64'
            )
        );

        return $mimeMessage;

    }
    
Если с заменой изображений все понятно, то вот со ссылкой позволяющей отписаться мы пока подождем, так как она должна быть своей для каждого подписчика.

Составление списка получателей

Прежде чем отправлять письмо необходимо определить список получателей. Для этого воспользуемся классом Swift_RecipientList и определим метод getRecipients в классе нашей команды, код может выглядеть примерно так:

    // lib/task/mailerSendTask.class.php
    protected $subscribers;
    // ...
    public function getRecipients()
    {
        $this->subscribers = Doctrine::getTable('Subscriber')->findAll();

        $recipients = new Swift_RecipientList;
        $encoder = new Swift_Message_Encoder;

        foreach($subscribers as $subscriber)
        {
            $recipients->addTo
            (
                $subscriber['email'],
                sprintf
                (
                    '=?utf-8?B?%s=',
                    $encoder->base64Encode
                    (
                        sprintf("%s %s", $subsciber['first_name'], $subscriber['last_name'])
                    )
                )
            );
        }

        return $recipients;
    }
    
Здесь используется Swift_Message_Encoder для кодирования получателей. Это необходимо, так как если в имени получателей содержатся например кириллические символы, то могут быть проблемы. Во избежание этих проблем и для адекватного отображения почтовыми клиентами мы и используем кодирование имен.

Отправка email сообщения

Для отправки нам так же необходимы некоторые настройки, которые лучше вынести в файл настроек приложения app.yml Выглядеть он может примерно так:

    mail:
      subscribe:
        host: localhost
        port: 25
        from: robot@example.com
    
Сам код отправки email сообщения может выглядеть примерно так:

    public function execute($arguments=array(), $options=array())
    {
        $dbManager = new sfDatabaseManager($this->configuration);

        // получаем настройки
        $config = sfConfig::get('app_mail_subscribe');

        // создаем экземпляр мейлера
        $mailer = new Swift(new Swift_Connection_SMTP($config['host'], $config['port']));

        // получаем список получателей
        $recipients = $this->getRecipients();

        try
        {
            // создаем объект пакетной рассылки и отправляем письмо
            // по списку получателей
            $batch = Swift_BatchMailer($mailer);
            $batch->send($this->getMessage(), $recipients, $config['from']);

            // после отправки письма, ставим отметку у сообщения
            $message = Doctrine::getTable('Message')->find($this->message['id']);
            $message->sended = date('Y-m-d H:i');
            $message->save();
        }
        catch (Exception $e)
        {
            new throw sfException($e->getMessage());
        }
    }
    
Мы используем Swift_BatchMailer для отправки email сообщения по списку получателей.

Персонализация сообщения в email рассылке

У нас уже отправляются письма по списку подписчиков. Но мы еще не решили одной важной проблемы - вставка ссылки для возможности отписаться от рассылки. Конечно можно было бы пойти простым путем и сделать ссылку для всех одинаковой, а уже на этой странице запрашивать email по которому отправлять письмо с нужной ссылкой. Но это слишком не удобно. Более того, хотелось бы хоть как-то персонализировать сообщения и начинать их например с приветствия по имени каждого подписчика. Для этого мы используем плагин для Swift - Swift_Plugin_Decorator. Который позволяет производить замены в тексте сообщения для каждого получателя. Нам для этого необходимо создать массив замен по следующему принципу:

    $replacements = array
    (
        'email@address.com' => array('{key}' => 'value', '{key2}' => 'value 2'),
        // ...
    );
    
Все ключи (они не обязательно должны быть обрамлены {}, можно использовать что угодно, например %%key%%) заменяются на значения. Для использования плагина необходимо для начала создать массив замен. Для простоты будем создавать массив замен в момент получения списка получателей. Для этого добавим свойство классу команды mailerSendTask - $replacements и изменим код получения списка получателей.

    // lib/task/mailerSendTask.class.php
    protected $subscribers;
    protected $replacements = array();
    // ...
    public function getRecipients()
    {
        $this->subscribers = Doctrine::getTable('Subscriber')->findAll();

        $recipients = new Swift_RecipientList;
        $encoder = new Swift_Message_Encoder;

        foreach($subscribers as $subscriber)
        {
            $recipients->addTo
            (
                $subscriber['email'],
                sprintf
                (
                    '=?utf-8?B?%s=',
                    $encoder->base64Encode
                    (
                        sprintf("%s %s", $subsciber['first_name'], $subscriber['last_name'])
                    )
                )
            );

            $this->replacements[$subscriber['email']] = array
            (
                '{first_name}' => $subscriber['first_name'],
                '{middle_name}' => $subscriber['middle_name'],
                '{last_name}' => $subscriber['last_name'],
                '{email_key}' => $subscriber['email_key']
            );
        }

        return $recipients;
    }
    
Теперь немного модифицируем код отправки сообщения, указав, что мы хотим использовать плагин:

    public function execute($arguments=array(), $options=array())
    {
        $dbManager = new sfDatabaseManager($this->configuration);

        // получаем настройки
        $config = sfConfig::get('app_mail_subscribe');

        // создаем экземпляр мейлера
        $mailer = new Swift(new Swift_Connection_SMTP($config['host'], $config['port']));

        // получаем список получателей
        $recipients = $this->getRecipients();

        // подключаем плагин 
        $mailer->attachPlugin(new Swift_Plugin_Decorator($this->replacements), 'decorator');

        try
        {
            // создаем объект пакетной рассылки и отправляем письмо
            // по списку получателей
            $batch = Swift_BatchMailer($mailer);
            $batch->send($this->getMessage(), $recipients, $config['from']);

            // после отправки письма, ставим отметку у сообщения
            $message = Doctrine::getTable('Message')->find($this->message['id']);
            $message->sended = date('Y-m-d H:i');
            $message->save();

        }
        catch (Exception $e)
        {
            new throw sfException($e->getMessage());
        }
    }
    
Теперь в теле сообщения все вставки вида {first_name}, {middle_name}, {last_name}, {email_key} будут заменяться на соответствующие значения. Т.е. письмо может выглядеть примерно так:

    Здравствуйте, {first_name} {last_name}!
    ...

    ---
    Если вы хотите отписаться от данной рассылки, перейдите по <a href="http://example.com/unsubscribe/{email_key}">этой ссылке</a>
    
На этом все. За кадром остались многие вопросы создания хорошей email рассылки, например управление подписчиками, собщениями, составление писем, создание нескольких рассылок, запланированная рассылка и т.д. Но главной целью данного поста было показать как персонализировать письма при отправке по списку получателей.

[ читать дальше ]

вторник, 14 октября 2008 г.

Новое в symfony 1.2, часть 3

Request

Настройки path_info_array, path_info_key и relative_url_root перенесены из settings.yml в factories.yml (в секцию param). Это изменение убирает зависимость sfRequest и sfConfig. Эти три опции передаются в конструктор четвертым параметром. Форматы так же передаются как опция.

Константы класса sfRequest теперь имеют текстовое значение:

Константа Старое значение Новое значение
GET 2 GET
POST 4 POST
PUT 5 PUT
DELETE 6 DELETE
HEAD 7 HEAD
NONE 1 -
Методы getMethod() и getMethodName() теперь возвращают одинаковые значения. Метод getMethodName() - устарел. sfAction::getMethodNames() и соответствующий код в sfValidationExecutionFilter из sfCompat10Plugin удалены.

Response

Добавилась новый параметр для request в factory - send_http_headers. По умолчанию эта настройка установлена в true, исключая test окружение. Это изменение удаляет зависимость sfResponse и sfConfig.

Ответ (response) имеет новый метод getCharset(), который возвращает текущую кодировку ответа. Возвращаемая кодировка, меняется если изменить кодировку через установку "content-type".

Методы getStylesheets, getJavascripts могут возвращать все файлы упорядочено, если передать sfWebResponse::ALL как первый аргумент.


    $response = new sfWebResponse(new sfEventDispatcher());
    $response->addStylesheet('foo.css');
    $response->addStylesheet('bar.css', 'first');

    var_export($response->getStylesheets());

    // outputs
    array(
      'bar.css' => array(),
      'foo.css' => array(),
    )
    
sfWebResponse::ALL - это теперь значение по умолчанию. Добиться поведения как в symfony 1.1 можно передав в качестве параметра sfResponse::RAW

    var_export($response->getStylesheets(sfWebResponse::RAW));

    // outputs
    array(
      'first' =>
        array(
          'bar.css' => array (),
        ),
      '' =>
        array(
        'foo.css' => array(),
        ),
      'last' => array(),
    )
    
Все позиции (first, '', last) теперь также доступны как константы

    sfWebResponse::FIRST  === 'first'
    sfWebResponse::MIDDLE === ''
    sfWebResponse::LAST   === 'last'
    
Методы removeStylesheet() и removeJavascript() теперь принимают только один аргумент, имя файла, который надо удалить из ответа. Файл будет удален из всех доступных позиций.

[ читать дальше ]

суббота, 11 октября 2008 г.

Шаблоны ошибок и форматы запроса

В symonfy 1.1 была введена поддержка различных форматов запроса. Но там не доставало одного важного фрагмента: поддержки ошибок. Благодаря огромной работе проделаной Kris Wallsmith в symfony 1.2 этот маленький недостаток уже исправлен. Крис создал плагин реализующий поддержку ошибок в разных форматах. Позднее его плагин был включен в ядро symfony 1.2.

Что бы проще было объяснить как это работает, рассмотрим небольшой пример. Допустим есть приложение с модулем API возвращающим HTML, XML или JSON представление статьи (модель Article). Определим правила маршрутизации:


    // apps/frontend/config/routing.yml
    api_article:
      url: /api/article/:id.:sf_format
      param: { module: api, action: atricle }
      requirements:
      sf_format: (?:html|xml|json)

И соответствующее действие (action) в нашем модуле:

    class apiActions extends sfActions
    {
        public function executeArticle($request)
        {
            $this->article = Doctrine::getTable('Article')->find((int)$request->getParameter('id'));
            $this->forward404Unless($this->article);
        }
    }

Теперь, если в запросе передать идентификатор не существующей статьи, запрос будет перенаправлен на страницу с 404 ошибкой. Если при этом используется HTML формат (http://localhost/frontend_dev.php/api/article/1.html) в development окружении вы получите сообщение об ошибке sfError404Exception.

В production окружении будет показана страница с 404 ошибкой "Oops! Page Not Found".

Если изменить формат запроса на XML, то в development окружении получим ответ:

и в production окружении просто:

Т.е. ошибки теперь возвращаются в том же формате, что и запрос.

И это еще не все. Сообщения об ошибках можно настраивать для каждого формата, добавляя шаблоны в директорию проекта config/error/ или конкретного приложения apps/frontend/config/error/. Например, для того чтобы настроить сообщения об ошибках для XML запросов можно создать шаблон config/error/error.xml.php примерно следующего содержания:


    <?xml version="1.0" encoding="<?php echo sfConfig::get('sf_charset', 'UTF-8') ?>"?>
    <error>
      <code><?php echo $code ?></code>
      <message><?php echo $text ?></message>
    </error>

Что даст примерно следующий вывод:

В шаблонах ошибок доступны следующие переменные:

  • $code - код статуса ответа
  • $text - текст статуса ответа
  • $name - имя класса исключения
  • $message - сообщение исключения
  • $traces - массив содержащий полный PHP trace
  • $format - формат запроса

Но например в development окружении вместо страниц типа 404 ошибки отображаются страницы с выброшенным исключением. Как например при запросе не существующей статьи, отображается страница с исключением sfError404Exception.
Эти страницы можно настраивать за счет создания шаблонов исключений, например config/error/exception.xml.php.
Шаблоны исключений используемые по умолчанию хранятся в директории lib/exception/data/ в дистрибутиве symfony.
Для создания своего шаблона для какого-либо формата запроса, необходимо создать шаблон вида config/error/exception.FORMAT_NAME.php, где FORMAT_NAME - название формата, для которого создается шаблон. В своих шаблонах, можно подгружать шаблоны symfony используя следующую конструкцию:


    <?php include sfException::getTemplatePathForError('xml', true) ?>

навеяно: New in symfony 1.2: Error Templates and Request Formats

[ читать дальше ]

четверг, 9 октября 2008 г.

Новое в symfony 1.2, часть 2

Действия (Action)

В действиях теперь можно генерировать URL используя объект маршрутизатора (routing object) благодаря новому методу generateUrl

        public function executeIndex()
        {
            $this->redirect($this->generateUrl('homepage'));
        }
    
Метод generateUrl в параметрах принимает имя маршрута, массив параметров и флаг генерировать ли абсолютный URL.

Формы (Forms)

Добавлены два новых метода облегчающих работу с формами в шаблонах. Первый - hasErrors возвращает true если форма содержит какие-либо ошибки и false если ошибок нет. Этот метод так же возвращает false если форма не была привязана к данным. Это используется в шаблонах когда нужно вывести сообщение о том, что форма содержит ошибки.

    <?php if ($form->hasError()): ?>
      Форма содержит ошибки, исправть пожалуйста.
    <?php endif; ?>
    
Второй метод renderFormTag генерирует открывающий тег для формы. Он так же добавляет enctype атрибут, если это необходимо и скрытый тег, если метод не POST и не GET

    <?php echo $form->renderFormTag('@article_update', array('method' => 'PUT')) ?>
    
Если форма связана с Propel объектом метод renderFormTag() автоматически изменяет HTTP метод на POST для создания объекта и на PUT для редактирования объекта.

[ читать дальше ]

среда, 8 октября 2008 г.

Новое в symfony 1.2, часть 1

Имя приложения в CLI командах

Некоторые команды требуют в аргументах имя приложения, так как они используют соединение с базой данных. Но в некоторых из этих команд требовать имя приложения не логично. В symfony1.2 этот аргумент был заменен на опцию --application. Если не указать эту опцию, то symfony возьмет настройки для соединения с базой данных из файла config/databases.yml Примеры таких команд

        #propel
        ./symfony propel:build-all-load 
        ./symfony propel:build-all-load --application=frontend

        #doctrine
        ./symfony doctrine:build-all-load
        ./symfony doctrine:build-all-load --application=frontend
        ./symfony doctrine:migrate
        ./symfony doctrine:migrate --application=frontend
    

Поддержка методов PUT и DELETE в браузере

Теперь можно имитировать поддержку методов PUT и DELETE браузером используя метод POST и добавив специальный скыртый (hidden) параметр sf_method

        <form action='#' method='POST'>
            <input type='hidden' name='sf_method' value='PUT' />

            <!-- // ... -->
        </form>
    
используя эту форму вызвав sfRequest::getMethod получим PUT

Улучшения в "response"

Иногда требуется получить файлы таблиц стилей (stylesheets) и файлы с javascript кодом текущего ответа. Но в версии 1.1 методы getJavascripts и getStylesheets возрващают внутрее представление данных, что в общем оказывается не тем что ожидалось. В symfony 1.2 данные возвращаются в упорядоченом, готовом к использованию виде.

        array
        (
            'bar.css' => array(),
            'foo.css' => array()
        );
    

sfValidatorSchemaCompare валидатор

sfValidatorSchemaCompare стал немного удобнее в использовании

        // symfony 1.1 and 1.2
        $v = new sfValidatorSchemaCompare('left', sfValidatorSchemaCompare::EQUAL, 'right');

        // symfony 1.2 only
        $v = new sfValidatorSchemaCompare('left', '==', 'right');
    

[ читать дальше ]