Рассылка писем через Unione (php, Yii2) +1


В статье представлен код позволяющий отправлять транзакционные письма через сервис unione, делать HTTP запросы к REST апи, а так же отправлять обычные email по smtp используя общий класс отправитель различных сообщений.

В нашей предыдущей статье (Использование ООП подхода для рассылки писем через Unione (php, Yii2)) мы представили базовый класс Message наследуясь от которого можно создать письмо для рассылки через сервис unione просто определив в наследниках методы для получения подстановок в шаблоны писем. Для отправки писем использовался метод Message::send вызываемый на объекте класса наследника. Такой подход с отправкой сообщения через сам класс письма вызвал определённую критику.

В данной статье класс Message переработан так, чтобы письмо можно было отправлять через общий класс отправителя. Кроме того приведён код как можно с помощью этого класса отправить email по smtp, а так же HTTP запрос к REST апи.

Поскольку отправка письма через сервис unione фактически является просто отправкой HTTP запроса к REST апи сервиса, то в начале имеет смысл рассмотреть просто отправку любых HTTP запросов. Для их отправки создадим класс, который расширит класс yii\httpclient\Request и имплементирует созданный нами интерфейс app\interfaces\MessageInterface.

<?php

namespace app\models\Email;

use app\interfaces\MessageInterface;
use yii\httpclient\Client;
use yii\httpclient\Request;

class RestMessage extends Request implements MessageInterface
{
    public function __construct(
        Client $client,
        $config = []
    )
    {
        parent::__construct($config);
        $this->client = $client;
    }

    public function composeMessage()
    {
        return $this->getData();
    }

    /**
     * {@inheritdoc}
     */
    public function setData($data)
    {
        $this->data = $data;
        $data = $this->composeMessage();

        return parent::setData($data);
    }
}

В данном классе переопределяется базовый метод Request::setData так чтобы данные получались с помощью метода RestMessage::composeMessage. Тогда отправка HTTP GET запроса с помощью общего класса отправителя будет выглядеть следующем образом:

<?php

$client = new RestClient();
$client->baseUrl = 'https://habr.com/';

$restRequest = new RestMessage($client);
$restRequest
    ->setUrl('ru/post/688090/');

$sender = new Sender($client);
$res = $sender->send($restRequest);

Теперь реализуем на базовый класс UnioneRestMessage для отправки писем через сервис unione. Для этого унаследуем его от RestMessage. Вот его реализация:

<?php

namespace app\models\Email;

use yii\helpers\ArrayHelper;
use yii\httpclient\Client;
use yii\web\View;

abstract class UnioneRestMessage extends RestMessage
{
    protected array  $data;
    protected $template;
    protected $templatePath;
    protected $email;
    protected $subject;
    protected $from;
    protected $sender;
    protected $formatter;
    protected $useTemplate;
    protected $baseUrl;

    /**
     * Determines properties to be extracted from input objects
     * @return array
     */
    abstract public function getProperties(): array;

    /**
     * Determines substitution array for unione message body
     * @return array
     */
    abstract public function getSubstitutions(): array;
    abstract public function getEmailOptions(): array;

    /**
     * Constructor
     */
    public function __construct(
        Client $client,
        bool $useTemplate = false
    )
    {
        parent::__construct($client);

        $this->useTemplate  = $useTemplate;
    }

    public function composeMessage()
    {
        $this->prepareData($this->data);
        $message = [
            "message" => [
                "recipients"            => $this->recipients,
                "subject"               => $this->subject,
                "from_email"            => $this->from,
                "from_name"             => $this->sender,
                'global_substitutions'  => $this->getGlobalSubstitutions(),
                'options'               => $this->getOptions(),
            ]
        ];
        if ($this->useTemplate) {
            $message['message']['template_id'] = $this->template;

        } else {
            $message['message']['body'] = [
                "html"  => $this->render($this->templatePath)
            ];
        }
        return $message;
    }

    /**
     * Prepares data to be input into a message
     * @param $data
     */
    public function prepareData($data): void
    {
        $sub = ArrayHelper::toArray($data, $this->properties);
        foreach ($sub as $el) {
            foreach ($el as $key => $value) {
                $this->data[$key] = $value;
            }
        }
    }

    /**
     * Gets value from prepared data by the key
     */
    public function getSubstitution(string $name, string $email = null)
    {
        return $this->data[$name];
    }

    public function getGlobalSubstitution(array &$data, string $name)
    {
        return $data[$name];
    }

    public function getRecipients(): array
    {
        $recipients = [
            [
                "email"          => $this->email,
                "substitutions"  => $this->substitutions
            ]
        ];
        return $recipients;
    }

    public function render(string  $path)
    {
        $view = new View();
        return $view->renderFile($path, $this->getRenderVariables());
    }

    public function getGlobalSubstitutions(): array
    {
        return [];
    }

    public function prepareGlobalData(array $data): array
    {
        $sub = ArrayHelper::toArray($data, $this->globalProperties);
        $subData = [];
        foreach ($sub as $el) {
            foreach ($el as $key => $value) {
                $subData[$key] = $value;
            }
        }
        return $subData;
    }

    public function getRenderVariables(): array
    {
        return $this->getGlobalSubstitutions();
    }

    public function getBaseUrl()
    {
        return $this->baseUrl;
    }

    /**
     * @param mixed|string $templatePath
     */
    public function setTemplatePath($templatePath)
    {
        $this->templatePath = $templatePath;
        return $this;
    }

    /**
     * @param mixed $email
     */
    public function setEmail($email)
    {
        $this->email = $email;
        return $this;
    }

    /**
     * @param mixed $subject
     */
    public function setSubject($subject)
    {
        $this->subject = $subject;
        return $this;
    }

    /**
     * @param mixed|string $from
     */
    public function setFrom($from)
    {
        $this->from = $from;
        return $this;
    }

    /**
     * @param mixed|string $sender
     */
    public function setSender($sender)
    {
        $this->sender = $sender;
        return $this;
    }

    /**
     * @param mixed|string $formatter
     */
    public function setFormatter($formatter)
    {
        $this->formatter = $formatter;
        return $this;
    }

    /**
     * @param mixed|string $apiKey
     */
    public function setApiKey($apiKey)
    {
        $this->getHeaders()->set('X-API-KEY', "{$apiKey}");
        return $this;
    }
}

В этом классе как мы видим переопределяется метод RestMessage::composeMessage так чтобы в общем определить структуру тела запроса к апи сервиса, а получение конкретных данных вынесено в классы наследники представляющие собой конкретные типы писем, через объявленные абстрактными методы UnioneRestMessage::getProperties и UnioneRestMessage::getSubstitutions. Кроме того, по сравнению с классом Message тут также добавлены методы, которые раньше были в UniOneService такие как setSubject, setFrom, setSender и т.д.

Для того чтобы было понятно как пользоваться данным базовым классом хотим привести реализацию письма об успешной инвестиции пользователя в проект.

<?php

namespace app\models\Email;

use app\models\Investment\InvestmentReserve;
use app\models\User\User;
use Yii;

class NewReserveMailRestMessage extends UnioneRestMessage
{

    /**
     * @inheritDoc
     */
    public function getProperties(): array
    {
        return [
            User::class => [
                'fullname' => function (User $user) {
                    return $user->fullName;
                },
                'user_funds' => function (User $user) {
                    $reserved = array_reduce($user->activeInvestmentReserves, function (int $sum, InvestmentReserve $reserve) {
                        return $sum + $reserve->amount;
                    }, 0);
                    return $this->formatter->asDecimal(($user->userFunds->amount - $reserved)/100, 2);
                }
            ],
            InvestmentReserve::class => [
                'amount' => function (InvestmentReserve $reserve) {
                    return $this->formatter->asDecimal($reserve->amount / 100, 2);
                },
                'id_label' => function (InvestmentReserve $reserve) {
                    return $reserve->project->id_label;
                },
                'country' => function (InvestmentReserve $reserve) {
                    return $reserve->project->country->country;
                },
                'target' => function (InvestmentReserve $reserve) {
                    return $this->formatter->asDecimal($reserve->project->amount, 2);
                },
                'loan' => function (InvestmentReserve $reserve) {
                    return $reserve->project->loan_period;
                },
                'rate' => function (InvestmentReserve $reserve) {
                    return $reserve->project->profitability;
                },
                'image' => function (InvestmentReserve $reserve) {
                    $base = Yii::$app->params['api_url'];
                    return $base . $reserve->project->image;
                },
                'left' => function (InvestmentReserve $reserve) {
                    return $this->formatter->asDecimal($reserve->project->availableAmount->available_amount, 2);
                },
                'name' => function (InvestmentReserve $reserve) {
                    return $reserve->project->name;
                },
                'label' => function (InvestmentReserve $reserve) {
                    return $reserve->project->id_label;
                }
            ]
        ];
    }

    /**
     * @inheritDoc
     */
    public function getSubstitutions(): array
    {
        $baseUrl = Yii::$app->params['front_url'];
        $apiBaseUrl = Yii::$app->params['api_url'];
        return [
            "Name"               => $this->getSubstitution('fullname'),
            "Invested_amount"    => $this->getSubstitution('amount'),
            "P1_label"           => $this->getSubstitution('id_label'),
            "P1_name"            => $this->getSubstitution('name'),
            "P1_where"           => $this->getSubstitution('country'),
            "P1_left"            => $this->getSubstitution('left'),
            "P1_target"          => $this->getSubstitution('target'),
            "P1_loan_period"     => $this->getSubstitution('loan'),
            "P1_interest_rate"   => $this->getSubstitution('rate'),
            "P1_link_img"        => $this->getSubstitution('image'),
            "Account_balance"    => $this->getSubstitution('user_funds'),
            'api_url'            => $apiBaseUrl,
            'front_url'          => $baseUrl,
            'P1_link'            => $baseUrl . '/projects/project/' . $this->getSubstitution('id_label'),
            'logo'               => $baseUrl . '/images/logo.svg'
        ];
    }

    public function getEmailOptions(): array
    {
        return [];
    }
}

Метод $this->getSubstitution возвращает значение поля объявленного в методе getProperties.

Теперь мы можем отправить это письмо через сервис следующим образом:

<?php

$investment = InvestmentReserve::findOne(102);
$project    = $investment->project;
$user       = $investment->user;

$subject = "You invested in the Project \"$project->name\" $project->id_label";

$client = new RestClient();
$client->baseUrl = 'https://eu1.unione.io/en/transactional/api/v1/';
$newReserveMail = new NewReserveMailRestMessage($client);
$newReserveMail
    ->setMethod('POST')
    ->setUrl('email/send.json')
    ->setFormat(Client::FORMAT_JSON)
    ->setApiKey('secret')
    ->setFormatter(Yii::$app->formatter)
    ->setSender('Company name')
    ->setFrom('email@address.com')
    ->setSubject($subject)
    ->setEmail($user->email)
    ->setTemplatePath('/app/mail/unione/user/letter_reserve.php')
    ->setData([$user, $investment])

  $sender = new Sender($client);
  $res = $sender->send($newReserveMail);

Здесь методы setMethod, setUrl и setFormat наследуются от yii\httpclient\Request, setData определён в RestMessage, остальные в UnioneRestMessage.

С помощью класса UnioneRestMessage можно создавать письма для рассылки через сервис рассылок unione просто наследуясь от него и определяя методы getProperties и getSubstitutions и отправлять их с помощью общего класса отправителя Sender в котором может быть реализована логика отправки HTTP сообщений (запросов). Кроме того с помощью этого класса Sender можно также отправлять и email по smtp. Вот пример как это можно реализовать:

<?php

$client = Yii::$app->mailer;

$emailMessage = new MailMessage();
$emailMessage
    ->setFrom('from@email.ru')
    ->setTo('to@email.ru')
    ->setSubject('Hi there')
    ->setTextBody('Test message');

$sender = new Sender($client);
$res = $sender->send($emailMessage);

Здесь MailMessage просто обёртка над yii\swiftmailer\Message реализующая наш app\interfaces\MessageInterface

<?php
<?php

namespace app\models\Email;

class MailMessage extends \yii\swiftmailer\Message implements \app\interfaces\MessageInterface
{

}

Таким образом, с помощью класса Sender мы смогли отправить и простой запрос к http ресурсу, и подготовленный запрос к сервису unione, и email по smtp. Давайте в заключение рассмотрим его минимальную реализацию для того чтобы пример с отправкой писем через unione был полностью рабочим.

<?php

namespace app\services\backend\email;

use app\interfaces\MessageInterface;
use app\interfaces\RestSenderInterface;
use app\models\Email\MailMessage;
use app\models\Email\RestMessage;
use app\models\Email\UnioneRestMessage;
use app\services\backend\infrastructure\ClientInterface;
use yii\httpclient\Client;

class Sender implements RestSenderInterface
{
    /** @var Client $client */
    private $client;

    public function __construct(ClientInterface $client)
    {

        $this->client = $client;
    }

    public function send(MessageInterface $message)
    {
        /** @var RestMessage|UnioneRestMessage|MailMessage $message */
        return $this->client->send($message);
    }
}

Здесь в конструктор передаётся объект реализующий наш интерфейс \app\services\backend\infrastructure\ClientInterface, поэтому для того чтобы предыдущие примеры работали для фреймворка Yii2 пришлось переопределить их yii\httpclient\Client и компонент мейлера так чтобы они реализовывали этот ClientInterface интерфейс следующим образом.

// http client
<?php

namespace app\services\backend\infrastructure;

use yii\httpclient\Client;

class RestClient extends Client implements ClientInterface
{

}
// email client
<?php

namespace app\services\backend\email;

use app\services\backend\infrastructure\ClientInterface;
use yii\swiftmailer\Mailer;

class MailSender extends Mailer implements ClientInterface
{
    public function send($message)
    {
        $this->sendMessage($message);
    }
}

А для компонента мейлера ещё и подправить определение компонента мейлера в конфиге.

<?php 
use app\services\backend\email\MailSender;
// Конфигурация ...
'mailer' => [
        'class'            => MailSender::class,
//        'class'            => 'yii\swiftmailer\Mailer',
        'useFileTransport' => false,
        'htmlLayout'       => 'layouts/html',
        'transport'        => [...],
    ],