├── src ├── Notification │ └── Notification.php ├── Exception │ ├── NotifierException.php │ ├── ChannelNotFound.php │ ├── SendingMessageFailed.php │ └── SendingNotificationFailed.php ├── Channel │ ├── Sms │ │ ├── Texter.php │ │ ├── SmsNotification.php │ │ ├── SmsMessage.php │ │ ├── PlivoTexter.php │ │ ├── TwilioTexter.php │ │ └── SmsChannel.php │ ├── Email │ │ ├── Mailer.php │ │ ├── EmailNotification.php │ │ ├── EmailChannel.php │ │ ├── SimpleMailer.php │ │ └── EmailMessage.php │ ├── Channel.php │ └── Channels.php ├── Recipient │ ├── Recipient.php │ └── Recipients.php └── Notifier.php ├── phpstan.neon.dist ├── LICENSE ├── CHANGELOG.md ├── composer.json └── README.md /src/Notification/Notification.php: -------------------------------------------------------------------------------- 1 | getMessage(), 0, $error); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Channel/Channel.php: -------------------------------------------------------------------------------- 1 | recipients = $recipients; 19 | } 20 | 21 | /** 22 | * @return Traversable|Recipient[] 23 | */ 24 | public function getIterator() 25 | { 26 | return new ArrayIterator($this->recipients); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Channel/Channels.php: -------------------------------------------------------------------------------- 1 | channels[$channel->getName()] = $channel; 21 | } 22 | } 23 | 24 | public function get(string $channelName): Channel 25 | { 26 | if (!isset($this->channels[$channelName])) { 27 | throw ChannelNotFound::byName($channelName); 28 | } 29 | 30 | return $this->channels[$channelName]; 31 | } 32 | 33 | /** 34 | * @return Traversable|Channel[] 35 | */ 36 | public function getIterator() 37 | { 38 | return new ArrayIterator($this->channels); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Channel/Sms/SmsMessage.php: -------------------------------------------------------------------------------- 1 | from = $phoneNumber; 21 | 22 | return $this; 23 | } 24 | 25 | public function getFrom(): ?string 26 | { 27 | return $this->from; 28 | } 29 | 30 | public function to(string $phoneNumber) 31 | { 32 | $this->to = $phoneNumber; 33 | 34 | return $this; 35 | } 36 | 37 | public function getTo(): string 38 | { 39 | return $this->to; 40 | } 41 | 42 | public function text(string $text) 43 | { 44 | $this->text = $text; 45 | 46 | return $this; 47 | } 48 | 49 | public function getText(): string 50 | { 51 | return $this->text; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Notifier.php: -------------------------------------------------------------------------------- 1 | channels = $channels; 19 | } 20 | 21 | public function send(Notification $notification, Recipients $recipients): void 22 | { 23 | foreach ($recipients as $recipient) { 24 | foreach ($this->channels as $channel) { 25 | $channel->send($notification, $recipient); 26 | } 27 | } 28 | } 29 | 30 | public function sendVia(string $channelName, Notification $notification, Recipients $recipients): void 31 | { 32 | $channel = $this->channels->get($channelName); 33 | 34 | foreach ($recipients as $recipient) { 35 | $channel->send($notification, $recipient); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Nikola Posa 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## 4.0.0 - 2020-04-05 5 | 6 | ### Changed 7 | - PHPUnit 7.2 is now the minimum required version 8 | - Introduce `Channel` sub-component that deals with notification delivery channels (email, sms, and similar) 9 | - Replace `AbstractNotification` class with channel-specific Notification interfaces 10 | 11 | ## 3.0.1 - 2017-01-01 12 | ### Improved 13 | - Updated PHP-CS-fixer. 14 | 15 | ## 3.0.0 - 2016-09-06 16 | ### Backwards-incompatible changes 17 | - Channels concept for notifications and notify strategies 18 | - Removed `NotificationInterface::__invoke()` method 19 | - StrategyInterface::notify() replaces handle() method. 20 | - `MessageSender` in favor of `SendService` naming for message sender implementations. 21 | - Renamed `StrategyInterface` to `NotifyStrategyInterface`. 22 | - Removed `GenericNotification` 23 | - Removed `GenericContact` 24 | 25 | ## 2.2.x 26 | This release is abandoned, please consider upgrading to 3.x. 27 | 28 | 29 | [link-unreleased]: https://github.com/nikolaposa/rate-limit/compare/3.0.1...HEAD 30 | -------------------------------------------------------------------------------- /src/Channel/Email/EmailChannel.php: -------------------------------------------------------------------------------- 1 | mailer = $mailer; 23 | } 24 | 25 | public function getName(): string 26 | { 27 | return self::NAME; 28 | } 29 | 30 | public function send(Notification $notification, Recipient $recipient): void 31 | { 32 | if (!$notification instanceof EmailNotification) { 33 | return; 34 | } 35 | 36 | if (null === ($recipientEmail = $recipient->getRecipientContact(self::NAME, $notification))) { 37 | return; 38 | } 39 | 40 | $message = $notification->toEmailMessage($recipient); 41 | $message->to($recipientEmail, $recipient->getRecipientName()); 42 | 43 | try { 44 | $this->mailer->send($message); 45 | } catch (SendingMessageFailed $error) { 46 | throw SendingNotificationFailed::for(self::NAME, $notification, $recipient, $error); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nikolaposa/notifier", 3 | "description": "Extensible library for building notifications and sending them via different delivery channels.", 4 | "type": "library", 5 | "license": "MIT", 6 | "keywords": [ 7 | "notifier", 8 | "notification", 9 | "email", 10 | "sms" 11 | ], 12 | "authors": [ 13 | { 14 | "name": "Nikola Poša", 15 | "email": "posa.nikola@gmail.com", 16 | "homepage": "https://www.nikolaposa.in.rs" 17 | } 18 | ], 19 | "config": { 20 | "sort-packages": true 21 | }, 22 | "require": { 23 | "php": "^7.2", 24 | "guzzlehttp/guzzle": "^6.0" 25 | }, 26 | "require-dev": { 27 | "friendsofphp/php-cs-fixer": "^2.1", 28 | "phpstan/phpstan": "^0.12.10", 29 | "phpstan/phpstan-phpunit": "^0.12.6", 30 | "phpunit/phpunit": "^8.0" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "Notifier\\": "src/" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "Notifier\\Tests\\": "tests/" 40 | } 41 | }, 42 | "scripts": { 43 | "test": "phpunit --colors=always", 44 | "cs-fix": "php-cs-fixer fix --config=.php_cs", 45 | "stan": "phpstan analyse" 46 | }, 47 | "extra": { 48 | "branch-alias": { 49 | "dev-master": "4.0.x-dev" 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Exception/SendingNotificationFailed.php: -------------------------------------------------------------------------------- 1 | getShortName(), 29 | $channelName 30 | ), 0, $error); 31 | 32 | $exception->channelName = $channelName; 33 | $exception->notification = $notification; 34 | $exception->recipient = $recipient; 35 | 36 | return $exception; 37 | } 38 | 39 | public function getChannelName(): string 40 | { 41 | return $this->channelName; 42 | } 43 | 44 | public function getNotification(): Notification 45 | { 46 | return $this->notification; 47 | } 48 | 49 | public function getRecipient(): Recipient 50 | { 51 | return $this->recipient; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Channel/Sms/PlivoTexter.php: -------------------------------------------------------------------------------- 1 | authId = $authId; 29 | $this->authToken = $authToken; 30 | 31 | if (null === $httpClient) { 32 | $httpClient = new Client(); 33 | } 34 | 35 | $this->httpClient = $httpClient; 36 | } 37 | 38 | public function send(SmsMessage $message): void 39 | { 40 | try { 41 | $this->httpClient->post( 42 | self::API_BASE_URL . "/v1/Account/{$this->authId}/Message/", 43 | [ 44 | RequestOptions::AUTH => [$this->authId, $this->authToken], 45 | RequestOptions::JSON => [ 46 | 'src' => $message->getFrom(), 47 | 'dst' => $message->getTo(), 48 | 'text' => $message->getText(), 49 | ], 50 | ] 51 | ); 52 | } catch (GuzzleException $exception) { 53 | throw SendingMessageFailed::dueTo($exception); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Channel/Sms/TwilioTexter.php: -------------------------------------------------------------------------------- 1 | authId = $authId; 29 | $this->authToken = $authToken; 30 | 31 | if (null === $httpClient) { 32 | $httpClient = new Client(); 33 | } 34 | 35 | $this->httpClient = $httpClient; 36 | } 37 | 38 | public function send(SmsMessage $message): void 39 | { 40 | try { 41 | $this->httpClient->post( 42 | self::API_BASE_URL . "/2010-04-01/Accounts/{$this->authId}/Messages.json", 43 | [ 44 | RequestOptions::AUTH => [$this->authId, $this->authToken], 45 | RequestOptions::FORM_PARAMS => [ 46 | 'Body' => $message->getText(), 47 | 'From' => $message->getFrom(), 48 | 'To' => $message->getTo(), 49 | ], 50 | ] 51 | ); 52 | } catch (GuzzleException $exception) { 53 | throw SendingMessageFailed::dueTo($exception); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Channel/Sms/SmsChannel.php: -------------------------------------------------------------------------------- 1 | texter = $texter; 26 | $this->defaultSenderPhoneNumber = $defaultSenderPhoneNumber; 27 | } 28 | 29 | public function getName(): string 30 | { 31 | return self::NAME; 32 | } 33 | 34 | public function send(Notification $notification, Recipient $recipient): void 35 | { 36 | if (!$notification instanceof SmsNotification) { 37 | return; 38 | } 39 | 40 | if (null === ($recipientPhoneNumber = $recipient->getRecipientContact(self::NAME, $notification))) { 41 | return; 42 | } 43 | 44 | $message = $notification->toSmsMessage($recipient); 45 | $message->to($recipientPhoneNumber); 46 | 47 | if (null === $message->getFrom() && null !== $this->defaultSenderPhoneNumber) { 48 | $message->from($this->defaultSenderPhoneNumber); 49 | } 50 | 51 | try { 52 | $this->texter->send($message); 53 | } catch (SendingMessageFailed $error) { 54 | throw SendingNotificationFailed::for(self::NAME, $notification, $recipient, $error); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Channel/Email/SimpleMailer.php: -------------------------------------------------------------------------------- 1 | handler = $handler; 23 | } 24 | 25 | public function send(EmailMessage $message): void 26 | { 27 | $this->message = $message; 28 | 29 | $status = call_user_func( 30 | $this->handler ?? 'mail', 31 | $this->buildTo(), 32 | $this->buildSubject(), 33 | $this->buildMessage(), 34 | $this->buildHeaders() 35 | ); 36 | 37 | if (!$status) { 38 | $error = error_get_last(); 39 | throw SendingMessageFailed::dueTo(new ErrorException($error['message'] ?? 'Email has not been accepted for delivery')); 40 | } 41 | } 42 | 43 | private function buildTo(): string 44 | { 45 | return implode(', ', $this->message->getTo()); 46 | } 47 | 48 | private function buildSubject(): string 49 | { 50 | return $this->message->getSubject(); 51 | } 52 | 53 | private function buildMessage(): string 54 | { 55 | return wordwrap($this->message->getBody(), self::MESSAGE_LINE_CHARACTERS_LIMIT); 56 | } 57 | 58 | private function buildHeaders(): string 59 | { 60 | $headers = array_map(function (string $name, $value) { 61 | if (is_array($value)) { 62 | $value = implode(', ', $value); 63 | } 64 | 65 | return "$name: $value"; 66 | }, array_keys($this->message->getHeaders()), $this->message->getHeaders()); 67 | 68 | return implode("\r\n", $headers); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Channel/Email/EmailMessage.php: -------------------------------------------------------------------------------- 1 | headers; 21 | } 22 | 23 | public function from(string $email, string $name = null) 24 | { 25 | $this->headers['From'][] = $this->createAddress($email, $name); 26 | 27 | return $this; 28 | } 29 | 30 | public function getFrom(): array 31 | { 32 | return $this->headers['From'] ?? []; 33 | } 34 | 35 | public function sender(string $email, string $name = null) 36 | { 37 | $this->headers['Sender'] = $this->createAddress($email, $name); 38 | 39 | return $this; 40 | } 41 | 42 | public function getSender(): string 43 | { 44 | return $this->headers['Sender'] ?? ''; 45 | } 46 | 47 | public function replyTo(string $email, string $name = null) 48 | { 49 | $this->headers['Reply-To'][] = $this->createAddress($email, $name); 50 | 51 | return $this; 52 | } 53 | 54 | public function getReplyTo(): array 55 | { 56 | return $this->headers['Reply-To'] ?? []; 57 | } 58 | 59 | public function to(string $email, string $name = null) 60 | { 61 | $this->headers['To'][] = $this->createAddress($email, $name); 62 | 63 | return $this; 64 | } 65 | 66 | public function getTo(): array 67 | { 68 | return $this->headers['To'] ?? []; 69 | } 70 | 71 | public function cc(string $email, string $name = null) 72 | { 73 | $this->headers['Cc'][] = $this->createAddress($email, $name); 74 | 75 | return $this; 76 | } 77 | 78 | public function getCc(): array 79 | { 80 | return $this->headers['Cc'] ?? []; 81 | } 82 | 83 | public function bcc(string $email, string $name = null) 84 | { 85 | $this->headers['Bcc'][] = $this->createAddress($email, $name); 86 | 87 | return $this; 88 | } 89 | 90 | public function getBcc(): array 91 | { 92 | return $this->headers['Bcc'] ?? []; 93 | } 94 | 95 | public function subject(string $subject) 96 | { 97 | $this->subject = $subject; 98 | 99 | return $this; 100 | } 101 | 102 | public function getSubject(): string 103 | { 104 | return $this->subject; 105 | } 106 | 107 | public function textBody(string $textBody) 108 | { 109 | $this->body = $textBody; 110 | 111 | return $this; 112 | } 113 | 114 | public function htmlBody(string $htmlBody) 115 | { 116 | $this->body = $htmlBody; 117 | $this->headers['MIME-Version'] = '1.0'; 118 | $this->headers['Content-type'] = 'text/html; charset=utf-8'; 119 | 120 | return $this; 121 | } 122 | 123 | public function getBody(): string 124 | { 125 | return $this->body; 126 | } 127 | 128 | private function createAddress(string $email, ?string $name): string 129 | { 130 | return (null !== $name) ? $name . ' <' . $email . '>' : $email; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notifier 2 | 3 | [![Build Status][ico-build]][link-build] 4 | [![Code Quality][ico-code-quality]][link-code-quality] 5 | [![Code Coverage][ico-code-coverage]][link-code-coverage] 6 | [![Latest Version][ico-version]][link-packagist] 7 | [![PDS Skeleton][ico-pds]][link-pds] 8 | 9 | Extensible library for building notifications and sending them via different delivery channels. 10 | 11 | ## Installation 12 | 13 | The preferred method of installation is via [Composer](http://getcomposer.org/). Run the following command to install 14 | the latest version of a package and add it to your project's `composer.json`: 15 | 16 | ```bash 17 | composer require nikolaposa/notifier 18 | ``` 19 | 20 | ## Theory of operation 21 | 22 | Notifications are informative messages that are sent through different channels (i.e. email, SMS, mobile push) to notify 23 | users about certain events in the application. Notification is a higher-level abstraction, a concept that encapsulates a 24 | subject to be notified to the recipient, regardless of delivery channels through which that information can be 25 | communicated. From an architectural standpoint, notification is a domain concern. 26 | 27 | In order to minimize the coupling of your domain with the infrastructure for sending notifications, Notifier library was 28 | based on on unobtrusive interfaces that should be implemented by your objects in order to plug them into the workflow of 29 | the library. Those are: 30 | 31 | 1. `Notification` - marks the object as a notification that can be used with the Notifier library, 32 | 2. `Recipient` - represents the recipient of the notification which provides contact (i.e. email address, phone number) 33 | for a certain channel; typically implemented by a User domain object. 34 | 35 | For each channel through which Notification is supposed to be sent, Notification class should implement channel-specific 36 | interface, making the Notification suitable for sending via a specific channel. These interfaces declare message 37 | building methods, for example `EmailNotification::toEmailMessage()`, that convert the notification to a message sent by 38 | that particular channel. Channel-specific Notification interfaces extend the `Notification` interface itself, so you do 39 | not need to implement it explicitly. 40 | 41 | Channel component captures implementation details of how a Notification is sent via certain delivery channels. Specific 42 | channel implementation typically consists of: 43 | 44 | 1. channel-specific Notification interface, 45 | 2. Message class - transport-level message to which Notification gets converted, 46 | 3. `Channel` implementation responsible for the very act of sending the Notification. 47 | 48 | Out of the box, this library features facilities for sending notifications via email and SMS. The highly extensible 49 | design allows for implementing custom delivery channels. 50 | 51 | Finally, `Notifier` service is a facade that manages the entire process of sending a Notification to a list of 52 | Recipients via supported channels. It is the only service of this library that the calling code is supposed to interact 53 | with directly. 54 | 55 | ## Usage 56 | 57 | **Creating Notifications** 58 | 59 | ```php 60 | namespace App\Model; 61 | 62 | use Notifier\Channel\Email\EmailMessage; 63 | use Notifier\Channel\Sms\SmsMessage; 64 | use Notifier\Channel\Email\EmailNotification; 65 | use Notifier\Channel\Sms\SmsNotification; 66 | use Notifier\Recipient\Recipient; 67 | 68 | class TodoExpiredNotification implements EmailNotification, SmsNotification 69 | { 70 | /** @var Todo */ 71 | protected $todo; 72 | 73 | public function __construct(Todo $todo) 74 | { 75 | $this->todo = $todo; 76 | } 77 | 78 | public function toEmailMessage(Recipient $recipient): EmailMessage 79 | { 80 | return (new EmailMessage()) 81 | ->from('noreply@example.com') 82 | ->subject('Todo expired') 83 | ->htmlBody( 84 | 'Dear ' . $recipient->getRecipientName() . ',' 85 | . '

' 86 | . 'Your todo: ' . $this->todo->getText() . ' has expired.' 87 | ); 88 | } 89 | 90 | public function toSmsMessage(Recipient $recipient): SmsMessage 91 | { 92 | return (new SmsMessage()) 93 | ->text('Todo: ' . $this->todo->getText() . ' has expired'); 94 | } 95 | } 96 | ``` 97 | 98 | **Implementing Recipient** 99 | 100 | ```php 101 | namespace App\Model; 102 | 103 | use Notifier\Recipient\Recipient; 104 | 105 | class User implements Recipient 106 | { 107 | /** @var string */ 108 | protected $name; 109 | 110 | /** @var array */ 111 | protected $contacts; 112 | 113 | public function __construct(string $name, array $contacts) 114 | { 115 | $this->name = $name; 116 | $this->contacts = $contacts; 117 | } 118 | 119 | public function getName(): string 120 | { 121 | return $this->name; 122 | } 123 | 124 | public function getRecipientContact(string $channel, Notification $notification): ?string 125 | { 126 | return $this->contacts[$channel] ?? null; 127 | } 128 | 129 | public function getRecipientName(): string 130 | { 131 | return $this->name; 132 | } 133 | } 134 | ``` 135 | 136 | **Sending Notifications** 137 | 138 | ```php 139 | use Notifier\Channel\Channels; 140 | use Notifier\Channel\Email\EmailChannel; 141 | use Notifier\Channel\Email\SimpleMailer; 142 | use Notifier\Channel\Sms\SmsChannel; 143 | use Notifier\Channel\Sms\TwilioTexter; 144 | use Notifier\Notifier; 145 | use Notifier\Recipient\Recipients; 146 | 147 | $notifier = new Notifier(new Channels( 148 | new EmailChannel(new SimpleMailer()), 149 | new SmsChannel(new TwilioTexter('auth_id', 'auth_token')) 150 | )); 151 | 152 | $notifier->send( 153 | new TodoExpiredNotification($todo), 154 | new Recipients($user1, $user2, $user3) 155 | ); 156 | ``` 157 | 158 | ## Credits 159 | 160 | - [Nikola Poša][link-author] 161 | - [All Contributors][link-contributors] 162 | 163 | ## License 164 | 165 | Released under MIT License - see the [License File](LICENSE) for details. 166 | 167 | 168 | [ico-version]: https://poser.pugx.org/nikolaposa/notifier/v/stable 169 | [ico-build]: https://travis-ci.com/nikolaposa/notifier.svg?branch=master 170 | [ico-code-coverage]: https://scrutinizer-ci.com/g/nikolaposa/notifier/badges/coverage.png?b=master 171 | [ico-code-quality]: https://scrutinizer-ci.com/g/nikolaposa/notifier/badges/quality-score.png?b=master 172 | [ico-pds]: https://img.shields.io/badge/pds-skeleton-blue.svg 173 | 174 | [link-packagist]: https://packagist.org/packages/nikolaposa/notifier 175 | [link-build]: https://travis-ci.com/nikolaposa/notifier 176 | [link-code-coverage]: https://scrutinizer-ci.com/g/nikolaposa/notifier/code-structure 177 | [link-code-quality]: https://scrutinizer-ci.com/g/nikolaposa/notifier 178 | [link-pds]: https://github.com/php-pds/skeleton 179 | [link-author]: https://github.com/nikolaposa 180 | [link-contributors]: ../../contributors 181 | --------------------------------------------------------------------------------