├── 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 |
--------------------------------------------------------------------------------