├── .gitignore ├── LICENSE ├── README.md ├── composer.json └── src ├── Bootstrap.php ├── Mailer.php ├── Message.php ├── MessageInterface.php ├── controllers └── MailQueueController.php ├── migrations └── m161015_011408_mailqueue_init.php └── models └── MailQueue.php /.gitignore: -------------------------------------------------------------------------------- 1 | .idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Tigrov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | yii2-mailqueue 2 | ============== 3 | 4 | Yii2 mail queue component for [yii2-symfonymailer](https://www.yiiframework.com/extension/yiisoft/yii2-symfonymailer). 5 | 6 | [![Latest Stable Version](https://poser.pugx.org/Tigrov/yii2-mailqueue/v/stable)](https://packagist.org/packages/Tigrov/yii2-mailqueue) 7 | 8 | Limitation 9 | ------------ 10 | 11 | Since 1.1.6 requires PHP >= 8.1 12 | 13 | Installation 14 | ------------ 15 | 16 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/). 17 | 18 | Either run 19 | 20 | ``` 21 | php composer.phar require --prefer-dist tigrov/yii2-mailqueue "~1.1.1" 22 | ``` 23 | 24 | or add 25 | 26 | ``` 27 | "tigrov/yii2-mailqueue": "~1.1.6" 28 | ``` 29 | 30 | to the require section of your `composer.json` file. 31 | 32 | 33 | Configuration 34 | ------------- 35 | Once the extension is installed, add following code to your application configuration: 36 | 37 | ```php 38 | return [ 39 | // ... 40 | 'components' => [ 41 | 'mailer' => [ 42 | 'class' => 'tigrov\mailqueue\Mailer', 43 | 'table' => '{{%mail_queue}}', 44 | 'maxAttempts' => 5, 45 | 'attemptIntervals' => [0, 'PT10M', 'PT1H', 'PT6H'], 46 | 'removeFailed' => true, 47 | 'maxPerPeriod' => 10, 48 | 'periodSeconds' => 1, 49 | ], 50 | ], 51 | // ... 52 | ]; 53 | ``` 54 | 55 | Following properties are available for customizing the mail queue behavior. 56 | 57 | * `table` name of the database table to store emails added to the queue; 58 | * `maxAttempts` maximum number of sending attempts per email; 59 | * `attemptIntervals` seconds or interval specifications to delay between attempts to send a mail message, see http://php.net/manual/en/dateinterval.construct.php; 60 | * `removeFailed` indicator to remove mail messages which were not sent in `maxAttempts`; 61 | * `maxPerPeriod` number of mail messages which could be sent per `periodSeconds`; 62 | * `periodSeconds` period in seconds which indicate the time interval for `maxPerPeriod` option. 63 | 64 | 65 | Updating database schema 66 | ------------------------ 67 | 68 | Run `yii migrate` command in command line: 69 | 70 | ``` 71 | php yii migrate/up --migrationPath=@vendor/tigrov/yii2-mailqueue/src/migrations/ 72 | ``` 73 | 74 | Sending the mail queue 75 | ------------------------- 76 | 77 | To sending mails from the queue call `Yii::$app->mailer->sending()` or run the console command `yii mailqueue` which can be triggered by a CRON job: 78 | 79 | ``` 80 | * * * * * php /var/www/vhosts/domain.com/yii mailqueue/sending 81 | ``` 82 | 83 | After the mail message successfully sent it will be deleted from the queue. 84 | 85 | Usage 86 | ----- 87 | 88 | You can then send a mail to the queue as follows: 89 | 90 | ```php 91 | Yii::$app->mailer->compose('contact/html') 92 | ->setFrom('from@domain.com') 93 | ->setTo($form->email) 94 | ->setSubject($form->subject) 95 | ->setTextBody($form->body) 96 | ->delay('PT3M') // seconds or an interval specification to delay of sending the mail message, see http://php.net/manual/en/dateinterval.construct.php 97 | ->unique('unique key') // a unique key for the mail message, new message with the same key will replace the old one 98 | ->queue(); 99 | ``` 100 | 101 | You can still send mails directly with `yii2-swiftmailer`: 102 | 103 | ```php 104 | Yii::$app->mailer->compose('contact/html') 105 | ->setFrom('from@domain.com') 106 | ->setTo($form->email) 107 | ->setSubject($form->subject) 108 | ->setTextBody($form->body) 109 | ->send(); 110 | ``` 111 | 112 | License 113 | ------- 114 | 115 | [MIT](LICENSE) 116 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tigrov/yii2-mailqueue", 3 | "description": "Yii2 mail queue component for yii2-swiftmailer.", 4 | "keywords": ["yii2", "extension", "mail", "email", "queue", "delay"], 5 | "homepage": "https://github.com/tigrov/yii2-mailqueue", 6 | "type": "yii2-extension", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Sergei Tigrov", 11 | "email": "rrr-r@ya.ru" 12 | } 13 | ], 14 | "require": { 15 | "yiisoft/yii2": "~2.0.0", 16 | "yiisoft/yii2-symfonymailer": "~2.0.3" 17 | }, 18 | "autoload": { 19 | "psr-4": { 20 | "tigrov\\mailqueue\\": "src/" 21 | } 22 | }, 23 | "extra": { 24 | "bootstrap": "tigrov\\mailqueue\\Bootstrap" 25 | } 26 | } -------------------------------------------------------------------------------- /src/Bootstrap.php: -------------------------------------------------------------------------------- 1 | controllerMap['mailqueue'] = 'tigrov\mailqueue\controllers\MailQueueController'; 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/Mailer.php: -------------------------------------------------------------------------------- 1 | modelClass; 61 | $list = $modelClass::find() 62 | ->where(['and', ['<', 'attempts', $this->maxAttempts], ['<=', 'send_at', new Expression('now()')]]) 63 | ->orderBy(['id' => SORT_ASC]) 64 | ->all(); 65 | 66 | $timeUntil = microtime(true) + $this->periodSeconds; 67 | for ($i = 0; $i < count($list); ++$i) { 68 | // Pause if maximum mail messages were already sent per period 69 | if (($i + 1) % $this->maxPerPeriod == 0) { 70 | if ($timeUntil > microtime(true)) { 71 | time_sleep_until($timeUntil); 72 | } 73 | $timeUntil = microtime(true) + $this->periodSeconds; 74 | } 75 | 76 | /* @var $model MailQueue */ 77 | $model = $list[$i]; 78 | if ($model->getMessage()->send($this)) { 79 | // Delete from the queue if the mail message was successfully sent 80 | $model->delete(); 81 | } else { 82 | ++$model->attempts; 83 | if ($this->removeFailed && $model->attempts >= $this->maxAttempts) { 84 | // Delete from the queue if the number of attempts to send the mail message are exhausted 85 | $model->delete(); 86 | } else { 87 | $model->applyDelay($this->getAttemptInterval($model->attempts)); 88 | $model->save(false); 89 | } 90 | } 91 | } 92 | } 93 | 94 | /** 95 | * Get time interval of an attempt 96 | * 97 | * @param $attempt number of the attempt 98 | * @return integer|string seconds or interval specifications, see http://php.net/manual/en/dateinterval.construct.php 99 | */ 100 | public function getAttemptInterval($attempt) 101 | { 102 | if (!$this->attemptIntervals) { 103 | return 0; 104 | } 105 | 106 | $index = $attempt - 1; 107 | if ($index <= 0) { 108 | $index = 0; 109 | } elseif ($index >= count($this->attemptIntervals)) { 110 | $index = count($this->attemptIntervals) - 1; 111 | } 112 | 113 | return $this->attemptIntervals[$index]; 114 | } 115 | } -------------------------------------------------------------------------------- /src/Message.php: -------------------------------------------------------------------------------- 1 | _model === null) { 36 | $modelClass = \Yii::$app->getMailer()->modelClass; 37 | $this->_model = new $modelClass; 38 | } 39 | 40 | return $this->_model; 41 | } 42 | 43 | /** 44 | * Init the message using data from a model of `MailQueue`. 45 | * 46 | * @param MailQueue $model 47 | * @return $this 48 | */ 49 | public function initFromModel(MailQueue $model) 50 | { 51 | $this->_model = $model; 52 | $embedIds = []; 53 | 54 | $messageData = $model->getData(); 55 | if (empty($messageData)) { 56 | return $this; 57 | } 58 | 59 | foreach ($messageData as $name => $params) { 60 | if (in_array($name, self::MULTIPLE_VALUES)) { 61 | foreach ($params as $value) { 62 | if (in_array($name, self::BASE_ENCODED_VALUES) && isset($value[0])) { 63 | $value[0] = base64_decode($value[0]); 64 | } 65 | call_user_func_array(parent::class . '::' . $name, $value); 66 | } 67 | } elseif (in_array($name, self::EMBED_VALUES)) { 68 | foreach ($params as list($content, $options, $id)) { 69 | if (in_array($name, self::BASE_ENCODED_VALUES)) { 70 | $content = base64_decode($content); 71 | } 72 | $embedIds[$id] = call_user_func_array(parent::class . '::' . $name, [$content, $options]); 73 | } 74 | } else { 75 | if ($name == 'setHtmlBody' && !empty($embedIds)) { 76 | $params[0] = strtr($params[0], $embedIds); 77 | } 78 | call_user_func_array(parent::class . '::' . $name, $params); 79 | } 80 | } 81 | 82 | return $this; 83 | } 84 | 85 | /** 86 | * @inheritDoc 87 | */ 88 | public function send(MailerInterface $mailer = null) 89 | { 90 | try { 91 | return parent::send($mailer); 92 | } catch (TransportExceptionInterface $e) { 93 | if (in_array($e->getCode(), [502,554])) { 94 | throw $e; 95 | } 96 | 97 | $recipients = array_merge($this->getTo() ?: [], $this->getCc() ?: [], $this->getBcc() ?: []); 98 | \Yii::info($e->getMessage() . ' Filed recipients: ' . implode(', ', array_keys($recipients))); 99 | 100 | return false; 101 | } 102 | } 103 | 104 | /** 105 | * @inheritdoc 106 | */ 107 | public function delay($interval) 108 | { 109 | $this->getModel()->applyDelay($interval); 110 | 111 | return $this; 112 | } 113 | 114 | /** 115 | * @inheritdoc 116 | */ 117 | public function unique($key) 118 | { 119 | $this->getModel()->setUniqueKey($key); 120 | 121 | return $this; 122 | } 123 | 124 | /** 125 | * @inheritdoc 126 | */ 127 | public function queue() 128 | { 129 | return $this->getModel()->save(); 130 | } 131 | 132 | /** 133 | * @inheritdoc 134 | */ 135 | public function setCharset($charset): self 136 | { 137 | $this->getModel()->setData('setCharset', [$charset]); 138 | 139 | return parent::setCharset($charset); 140 | } 141 | 142 | /** 143 | * @inheritdoc 144 | */ 145 | public function setFrom($from): self 146 | { 147 | $this->getModel()->setData('setFrom', [$from]); 148 | 149 | return parent::setFrom($from); 150 | } 151 | 152 | /** 153 | * @inheritdoc 154 | */ 155 | public function setReplyTo($replyTo): self 156 | { 157 | $this->getModel()->setData('setReplyTo', [$replyTo]); 158 | 159 | return parent::setReplyTo($replyTo); 160 | } 161 | 162 | /** 163 | * @inheritdoc 164 | */ 165 | public function setTo($to): self 166 | { 167 | $this->getModel()->setData('setTo', [$to]); 168 | 169 | return parent::setTo($to); 170 | } 171 | 172 | /** 173 | * @inheritdoc 174 | */ 175 | public function setCc($cc): self 176 | { 177 | $this->getModel()->setData('setCc', [$cc]); 178 | 179 | return parent::setCc($cc); 180 | } 181 | 182 | /** 183 | * @inheritdoc 184 | */ 185 | public function setBcc($bcc): self 186 | { 187 | $this->getModel()->setData('setBcc', [$bcc]); 188 | 189 | return parent::setBcc($bcc); 190 | } 191 | 192 | /** 193 | * @inheritdoc 194 | */ 195 | public function setSubject($subject): self 196 | { 197 | $this->getModel()->setData('setSubject', [$subject]); 198 | 199 | return parent::setSubject($subject); 200 | } 201 | 202 | /** 203 | * @inheritdoc 204 | */ 205 | public function setTextBody($text): self 206 | { 207 | $this->getModel()->setData('setTextBody', [$text]); 208 | 209 | return parent::setTextBody($text); 210 | } 211 | 212 | /** 213 | * @inheritdoc 214 | */ 215 | public function setHtmlBody($html): self 216 | { 217 | $this->getModel()->setData('setHtmlBody', [$html]); 218 | 219 | return parent::setHtmlBody($html); 220 | } 221 | 222 | /** 223 | * @inheritdoc 224 | */ 225 | public function attach($fileName, array $options = []) 226 | { 227 | $this->getModel()->addData('attach', [$fileName, $options]); 228 | 229 | return parent::attach($fileName, $options); 230 | } 231 | 232 | /** 233 | * @inheritdoc 234 | */ 235 | public function attachContent($content, array $options = []) 236 | { 237 | $this->getModel()->addData('attachContent', [base64_encode($content), $options]); 238 | 239 | return parent::attachContent($content, $options); 240 | } 241 | 242 | /** 243 | * @inheritdoc 244 | */ 245 | public function embed($fileName, array $options = []) 246 | { 247 | $id = parent::embed($fileName, $options); 248 | $this->getModel()->addData('embed', [$fileName, $options, $id]); 249 | 250 | return $id; 251 | } 252 | 253 | /** 254 | * @inheritdoc 255 | */ 256 | public function embedContent($content, array $options = []) 257 | { 258 | $id = parent::embedContent($content, $options); 259 | $this->getModel()->addData('embedContent', [base64_encode($content), $options, $id]); 260 | 261 | return $id; 262 | } 263 | 264 | /** 265 | * @inheritdoc 266 | */ 267 | public function addHeader($name, $value): self 268 | { 269 | $this->getModel()->addData('addHeader', [$name, $value]); 270 | 271 | return parent::addHeader($name, $value); 272 | } 273 | 274 | /** 275 | * @inheritdoc 276 | */ 277 | public function setHeader($name, $value): self 278 | { 279 | $this->getModel()->setData('setHeader', [$name, $value]); 280 | 281 | return parent::setHeader($name, $value); 282 | } 283 | 284 | /** 285 | * @inheritdoc 286 | */ 287 | public function setReturnPath($address): self 288 | { 289 | $this->getModel()->setData('setReturnPath', [$address]); 290 | 291 | return parent::setReturnPath($address); 292 | } 293 | 294 | /** 295 | * @inheritdoc 296 | */ 297 | public function setPriority($priority): self 298 | { 299 | $this->getModel()->setData('setPriority', [$priority]); 300 | 301 | return parent::setPriority($priority); 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /src/MessageInterface.php: -------------------------------------------------------------------------------- 1 | mailer->compose('contact/html') 19 | * ->setFrom('from@domain.com') 20 | * ->setTo($form->email) 21 | * ->setSubject($form->subject) 22 | * ->setTextBody($form->body) 23 | * ->delay('PT3M') // seconds or an interval specification to delay of sending the mail message, see http://php.net/manual/en/dateinterval.construct.php 24 | * ->unique('unique key') // a unique key for the mail message, new message with the same key will replace the old one 25 | * ->queue(); 26 | * ``` 27 | * 28 | * You can still send mails directly with `yii2-swiftmailer`: 29 | * 30 | * ```php 31 | * Yii::$app->mailer->compose('contact/html') 32 | * ->setFrom('from@domain.com') 33 | * ->setTo($form->email) 34 | * ->setSubject($form->subject) 35 | * ->setTextBody($form->body) 36 | * ->send(); 37 | * ``` 38 | * 39 | * @see MailerInterface 40 | */ 41 | interface MessageInterface extends \yii\mail\MessageInterface 42 | { 43 | /** 44 | * @param integer|string $interval seconds or an interval specification to delay of sending the mail message, see http://php.net/manual/en/dateinterval.construct.php 45 | * @return $this 46 | */ 47 | public function delay($interval); 48 | 49 | /** 50 | * @param string $key a unique key for the mail message, new message with the same key will replace old one 51 | * @return $this 52 | */ 53 | public function unique($key); 54 | 55 | /** 56 | * Enqueue the mail message storing it in the table. 57 | * 58 | * @return boolean true on success, false otherwise 59 | */ 60 | public function queue(); 61 | } -------------------------------------------------------------------------------- /src/controllers/MailQueueController.php: -------------------------------------------------------------------------------- 1 | getMailer()->sending(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/migrations/m161015_011408_mailqueue_init.php: -------------------------------------------------------------------------------- 1 | db->driverName === 'mysql') { 16 | $tableOptions = 'CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE=InnoDB'; 17 | } 18 | 19 | $tableName = \Yii::$app->getMailer()->table; 20 | $this->createTable($tableName, [ 21 | 'id' => Schema::TYPE_PK, 22 | 'unique_key' => Schema::TYPE_STRING . ' NULL DEFAULT NULL UNIQUE', 23 | 'message_data' => Schema::TYPE_TEXT . ' NOT NULL', 24 | 'attempts' => Schema::TYPE_SMALLINT . ' NOT NULL DEFAULT 0', 25 | 'send_at' => Schema::TYPE_DATETIME . ' NOT NULL DEFAULT now()', 26 | ], $tableOptions); 27 | 28 | $path = explode('.', $this->db->schema->getRawTableName($tableName)); 29 | $indexName = 'idx_' . end($path) . '_send_at'; 30 | $this->createIndex($indexName, $tableName, ['send_at']); 31 | } 32 | 33 | public function down() 34 | { 35 | $this->dropTable(\Yii::$app->getMailer()->table); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/models/MailQueue.php: -------------------------------------------------------------------------------- 1 | getMailer()->table; 31 | } 32 | 33 | /** 34 | * @inheritdoc 35 | */ 36 | public function rules() 37 | { 38 | return [ 39 | [['message_data'], 'required'], 40 | [['unique_key'], 'string'], 41 | [['attempts'], 'integer'], 42 | [['send_at'], 'safe'], 43 | ]; 44 | } 45 | 46 | /** 47 | * @param integer|string $interval seconds or an interval specification to delay of sending the mail message, see http://php.net/manual/en/dateinterval.construct.php 48 | */ 49 | public function applyDelay($interval) 50 | { 51 | if ($interval) { 52 | if (is_integer($interval)) { 53 | $interval = 'PT' . $interval . 'S'; 54 | } 55 | 56 | $this->setSendAt((new \DateTime)->add(new \DateInterval($interval))->format('Y-m-d H:i:s')); 57 | } 58 | } 59 | 60 | /** 61 | * @param string $datetime 62 | */ 63 | public function setSendAt($datetime) 64 | { 65 | $this->send_at = $datetime; 66 | } 67 | 68 | /** 69 | * @param string $key 70 | */ 71 | public function setUniqueKey($key) 72 | { 73 | $this->unique_key = $key; 74 | } 75 | 76 | /** 77 | * @param string $name 78 | * @param array $params 79 | */ 80 | public function setData($name, $params) 81 | { 82 | $this->_data[$name] = $params; 83 | } 84 | 85 | /** 86 | * @param string $name 87 | * @param array $params 88 | */ 89 | public function addData($name, $params) 90 | { 91 | $this->_data[$name][] = $params; 92 | } 93 | 94 | /** 95 | * @return array 96 | */ 97 | public function getData() 98 | { 99 | return $this->_data; 100 | } 101 | 102 | /** 103 | * @return Message 104 | */ 105 | public function getMessage() 106 | { 107 | /* @var $messageClass Message */ 108 | $messageClass = \Yii::$app->getMailer()->messageClass; 109 | 110 | return (new $messageClass)->initFromModel($this); 111 | } 112 | 113 | /** 114 | * @inheritdoc 115 | * 116 | * Replace old data if unique_key already exists. 117 | */ 118 | public function insert($runValidation = true, $attributes = null) 119 | { 120 | if ($this->unique_key !== null) { 121 | if ($model = static::findOne(['unique_key' => $this->unique_key])) { 122 | $model->setAttributes($this->getDirtyAttributes()); 123 | $model->decodeData(); 124 | 125 | $result = $model->save(); 126 | 127 | $this->setAttributes($model->getAttributes(), false); 128 | $this->setIsNewRecord(false); 129 | $this->addErrors($model->getErrors()); 130 | $this->decodeData(); 131 | 132 | return $result; 133 | } 134 | } 135 | 136 | return parent::insert($runValidation, $attributes); 137 | } 138 | 139 | public function decodeData() 140 | { 141 | $this->_data = json_decode($this->message_data, true); 142 | } 143 | 144 | public function encodeData() 145 | { 146 | $this->message_data = json_encode($this->_data); 147 | } 148 | 149 | /** 150 | * @inheritdoc 151 | */ 152 | public function beforeValidate() 153 | { 154 | $this->encodeData(); 155 | 156 | return parent::beforeValidate(); 157 | } 158 | 159 | /** 160 | * @inheritdoc 161 | */ 162 | public function beforeSave($insert) 163 | { 164 | $this->encodeData(); 165 | 166 | return parent::beforeSave($insert); 167 | } 168 | 169 | /** 170 | * @inheritdoc 171 | */ 172 | public function afterFind() 173 | { 174 | $this->decodeData(); 175 | 176 | parent::afterFind(); 177 | } 178 | } 179 | --------------------------------------------------------------------------------