├── .github ├── ISSUE_TEMPLATE │ └── issue-template.md └── workflows │ └── run-tests.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── UPGRADING.md ├── composer.json ├── config └── database-emails.php ├── database └── migrations │ └── 2024_03_16_151608_create_new_table.php ├── docker-compose.yml ├── phpunit.xml ├── src ├── Attachment.php ├── Config.php ├── Email.php ├── EmailComposer.php ├── LaravelDatabaseEmailsServiceProvider.php ├── MailableReader.php ├── MessageSent.php ├── SendEmailJob.php ├── SendEmailsCommand.php ├── Sender.php ├── SentMessage.php └── Store.php ├── testbench.yaml ├── tests ├── ComposeTest.php ├── ConfigCacheTest.php ├── ConfigTest.php ├── DatabaseInteractionTest.php ├── EnvelopeTest.php ├── MailableReaderTest.php ├── PruneTest.php ├── QueuedEmailsTest.php ├── SendEmailsCommandTest.php ├── SenderTest.php ├── TestCase.php ├── files │ ├── my-file.txt │ └── pdf-sample.pdf └── views │ ├── dummy.blade.php │ └── welcome.blade.php └── workbench ├── app ├── Jobs │ └── CustomSendEmailJob.php ├── Models │ ├── .gitkeep │ ├── User.php │ ├── UserWithPreferredEmail.php │ ├── UserWithPreferredLocale.php │ └── UserWithPreferredName.php └── Providers │ └── WorkbenchServiceProvider.php ├── bootstrap ├── .gitkeep ├── app.php └── providers.php ├── database ├── factories │ └── .gitkeep ├── migrations │ └── .gitkeep └── seeders │ ├── .gitkeep │ └── DatabaseSeeder.php ├── lang ├── en │ └── messages.php └── fil-PH │ └── messages.php ├── resources └── views │ ├── .gitkeep │ └── locale-email.blade.php ├── routes ├── .gitkeep ├── console.php └── web.php └── storage └── app └── public └── test.txt /.github/ISSUE_TEMPLATE/issue-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue template 3 | about: Help you create an effective issue and know what to expect. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Thank you for taking the time for reporting a bug, requesting a feature, or letting me know something else about the package. 11 | 12 | I create open-source packages for fun while working full-time and running my own business. That means I don't have as much time left to maintain these packages, build elaborate new features or investigate and fix bugs. If you wish to get a feature or bugfix merged it would be greatly appreciated if you can provide as much info as possible and preferably a Pull Request ready with automated tests. Realistically I check Github a few times a week, and take several days, weeks or sometimes months before finishing features/bugfixes (depending on their size of course). 13 | 14 | Thanks for understanding. 😁 15 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | pull_request_target: 5 | types: [opened, synchronize, labeled] 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | access_check: 11 | runs-on: ubuntu-latest 12 | name: Access check 13 | steps: 14 | - name: Ensure pull-request is safe to run 15 | uses: actions/github-script@v7 16 | with: 17 | github-token: ${{secrets.GITHUB_TOKEN}} 18 | script: | 19 | if (context.eventName === 'schedule') { 20 | return 21 | } 22 | 23 | // If the user that pushed the commit is a maintainer, skip the check 24 | const collaborators = await github.rest.repos.listCollaborators({ 25 | owner: context.repo.owner, 26 | repo: context.repo.repo 27 | }); 28 | 29 | if (collaborators.data.some(c => c.login === context.actor)) { 30 | console.log(`User ${context.actor} is allowed to run tests because they are a collaborator.`); 31 | return 32 | } 33 | 34 | const issue_number = context.issue.number; 35 | const repository = context.repo.repo; 36 | const owner = context.repo.owner; 37 | 38 | const response = await github.rest.issues.listLabelsOnIssue({ 39 | owner, 40 | repo: repository, 41 | issue_number 42 | }); 43 | const labels = response.data.map(label => label.name); 44 | let hasLabel = labels.includes('safe-to-test') 45 | 46 | if (context.payload.action === 'synchronize' && hasLabel) { 47 | hasLabel = false 48 | await github.rest.issues.removeLabel({ 49 | owner, 50 | repo: repository, 51 | issue_number, 52 | name: 'safe-to-test' 53 | }); 54 | } 55 | 56 | if (!hasLabel) { 57 | throw "Action was not authorized. Exiting now." 58 | } 59 | 60 | php-tests: 61 | runs-on: ubuntu-latest 62 | needs: access_check 63 | strategy: 64 | matrix: 65 | db: [ 'mysql', 'sqlite', 'pgsql' ] 66 | payload: 67 | - { laravel: '11.*', php: '8.3', 'testbench': '9.*', collision: '8.*' } 68 | - { laravel: '11.*', php: '8.2', 'testbench': '9.*', collision: '8.*' } 69 | - { laravel: '12.*', php: '8.2', 'testbench': '10.*', collision: '8.*' } 70 | - { laravel: '12.*', php: '8.3', 'testbench': '10.*', collision: '8.*' } 71 | - { laravel: '12.*', php: '8.4', 'testbench': '10.*', collision: '8.*' } 72 | 73 | name: PHP ${{ matrix.payload.php }} - Laravel ${{ matrix.payload.laravel }} - DB ${{ matrix.db }} 74 | 75 | steps: 76 | - name: Checkout code 77 | uses: actions/checkout@v4 78 | with: 79 | ref: ${{ github.event.pull_request.head.sha }} 80 | 81 | - name: Setup PHP 82 | uses: shivammathur/setup-php@v2 83 | with: 84 | php-version: ${{ matrix.payload.php }} 85 | extensions: mbstring, dom, fileinfo, mysql 86 | coverage: none 87 | 88 | - name: Set up MySQL and PostgreSQL 89 | run: | 90 | if [ "${{ matrix.db }}" != "sqlite" ]; then 91 | MYSQL_PORT=3307 POSTGRES_PORT=5432 docker compose up ${{ matrix.db }} -d 92 | fi 93 | 94 | - name: Install dependencies 95 | run: | 96 | composer require "laravel/framework:${{ matrix.payload.laravel }}" "orchestra/testbench:${{ matrix.payload.testbench }}" "nunomaduro/collision:${{ matrix.payload.collision }}" --no-interaction --no-update 97 | composer update --prefer-stable --prefer-dist --no-interaction 98 | if [ "${{ matrix.db }}" = "mysql" ]; then 99 | while ! mysqladmin ping --host=127.0.0.1 --user=test --port=3307 --password=test --silent; do 100 | echo "Waiting for MySQL..." 101 | sleep 1 102 | done 103 | else 104 | echo "Not waiting for MySQL." 105 | fi 106 | - name: Execute tests 107 | env: 108 | DB_DRIVER: ${{ matrix.db }} 109 | run: composer test 110 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | vendor/ 3 | .DS_Store 4 | composer.lock 5 | .phpunit.result.cache 6 | .phpunit.cache 7 | test 8 | 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Releases 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## 7.0.0 - 2024-03-24 8 | 9 | **Added** 10 | 11 | - Laravel 10 and 11 support 12 | - Customizable job class for queueing 13 | - Index on emails table to improve performance 14 | - Added support for SQLite and PostgreSQL 15 | 16 | **Changed** 17 | 18 | - Email::compose() has changed. See UPGRADING.md 19 | - Old email table is incompatible - new table will be created 20 | 21 | **Removed** 22 | 23 | - Support for Laravel 6, 7, 8 and 9 24 | - Email encyption 25 | 26 | ## 6.3.0 - 2023-12-30 27 | 28 | **Added** 29 | 30 | - ReplyTo feature 31 | 32 | ## 6.2.1 - 2023-12-28 33 | 34 | **Changed** 35 | 36 | - Test package with PHP 8.3 37 | 38 | ## 6.2.0 - 2023-04-09 39 | 40 | **Added** 41 | 42 | - Added support for Laravel 10 style mailables 43 | 44 | ## 6.1.0 - 2023-02-08 45 | 46 | **Changed** 47 | 48 | - Added support for Laravel 10 49 | 50 | ## 6.0.0 - 2022-02-10 51 | 52 | **Added** 53 | 54 | - Added support for Laravel 9 with new Symfony Mailer instead of SwiftMail. 55 | 56 | **Changed** 57 | 58 | - Dropped support for Laravel 5.6, 5.7 and 5.8. 59 | 60 | ## 5.0.0 - 2021-12-05 61 | 62 | **Added** 63 | 64 | - Option to switch from auto-loaded migrations to manually published. Useful for using a multi tenant Laravel app (stancl/tenancy for example). 65 | 66 | **Fixed** 67 | 68 | - Before, when an email was queued using `queue()` it could still be sent using the `email:send` command, thus resulting in duplicate email sends. This has been fixed by adding a `queued_at` column. 69 | 70 | ## 4.2.0 - 2020-05-16 71 | 72 | **Added** 73 | 74 | - Support for Laravel 7.x 75 | - Queued option 76 | 77 | ## 4.1.1 - 2020-01-11 78 | 79 | **Fixed** 80 | 81 | - Fixed inline attachments could not be stored 82 | - Fixed PHP 7.4 issue when reading empty Mailable from address 83 | 84 | ## 4.1.0 - 2019-07-13 85 | 86 | **Added** 87 | 88 | - Option to send e-mails immediately after calling send() or later() 89 | 90 | **Changed** 91 | 92 | - attach() and attachData() will no longer add empty or null files 93 | 94 | ## 4.0.2 - 2019-01-01 95 | 96 | **Fixed** 97 | 98 | - Fixed regression bug (testing mode) 99 | 100 | ## 4.0.1 - 2018-12-31 101 | 102 | **Added** 103 | 104 | - New environment variable `LARAVEL_DATABASE_EMAILS_TESTING_ENABLED` to indicate if testing mode is enabled (*) 105 | 106 | **Fixed** 107 | 108 | - Fixed issue where Mailables would not be read correctly 109 | - Config file was not cachable (*) 110 | 111 | (*) = To be able to cache the config file, change the 'testing' closure to the environment variable as per `laravel-database-emails.php` config file. 112 | 113 | ## 4.0.0 - 2018-09-15 114 | 115 | **Changed** 116 | 117 | - Changed package namespace 118 | 119 | **Removed** 120 | 121 | - Removed resend/retry option entirely 122 | - Removed process time limit 123 | 124 | ## 3.0.3 - 2018-07-24 125 | 126 | **Fixed** 127 | 128 | - Transforming an `Email` object to JSON would cause the encrpyted attributes to stay encrypted. This is now fixed. 129 | 130 | ## 3.0.2 - 2018-03-22 131 | 132 | **Changed** 133 | 134 | - Updated README.md 135 | 136 | **Added** 137 | 138 | - Support for process time limit 139 | 140 | --- 141 | 142 | ## 3.0.1 - 2018-03-18 143 | 144 | **Changed** 145 | 146 | - Updated README.md 147 | - Deprecated `email:retry`, please use `email:resend` 148 | 149 | --- 150 | 151 | ## 3.0.0 - 2017-12-22 152 | 153 | **Added** 154 | 155 | - Support for a custom sender per e-mail. 156 | 157 | **Upgrade from 2.x to 3.x** 158 | 159 | 3.0.0 added support for a custom sender per e-mail. To update please run the following command: 160 | 161 | ```bash 162 | php artisan migrate 163 | ``` 164 | 165 | --- 166 | 167 | ## 2.0.0 - 2017-12-14 168 | 169 | **Added** 170 | 171 | - Support for multiple recipients, cc and bcc addresses. 172 | - Support for mailables (*) 173 | - Support for attachments 174 | - New method `later` 175 | 176 | *= Only works for Laravel versions 5.5 and up because 5.5 finally introduced a method to read the mailable body. 177 | 178 | **Fixed** 179 | - Bug causing failed e-mails not to be resent 180 | 181 | **Upgrade from 1.x to 2.x** 182 | Because 2.0.0 introduced support for attachments, the database needs to be updated. Simply run the following two commands after updating your dependencies and running composer update: 183 | 184 | ```bash 185 | php artisan migrate 186 | ``` 187 | 188 | --- 189 | 190 | ## 1.1.3 - 2017-12-07 191 | 192 | **Fixed** 193 | 194 | - Created a small backwards compatibility fix for Laravel versions 5.4 and below. 195 | 196 | --- 197 | 198 | ## 1.1.2 - 2017-11-18 199 | 200 | **Fixed** 201 | 202 | - Incorrect auto discovery namespace for Laravel 5.5 203 | 204 | --- 205 | 206 | ## 1.1.1 - 2017-08-02 207 | 208 | **Changed** 209 | 210 | - Only dispatch `before.send` event during unit tests 211 | 212 | --- 213 | 214 | ## 1.1.0 - 2017-07-01 215 | 216 | **Added** 217 | 218 | - PHPUnit tests 219 | - Support for CC and BCC 220 | 221 | --- 222 | 223 | ## 1.0.0 - 2017-06-29 224 | 225 | **Added** 226 | 227 | - Initial release of the package 228 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Marick van Tuil 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 | [![Run tests](https://github.com/stackkit/laravel-database-emails/actions/workflows/run-tests.yml/badge.svg)](https://github.com/stackkit/laravel-database-emails/actions/workflows/run-tests.yml) 2 | [![Latest Version on Packagist](https://poser.pugx.org/stackkit/laravel-database-emails/v/stable.svg)](https://packagist.org/packages/stackkit/laravel-database-emails) 3 | [![Total Downloads](https://poser.pugx.org/stackkit/laravel-database-emails/downloads.svg)](https://packagist.org/packages/stackkit/laravel-database-emails) 4 | 5 | # Introduction 6 | 7 | This package allows you to store and send e-mails using the database. 8 | 9 | # Requirements 10 | 11 | This package requires Laravel 11 or 12. 12 | 13 | # Installation 14 | 15 | Require the package using composer. 16 | 17 | ```shell 18 | composer require stackkit/laravel-database-emails 19 | ``` 20 | 21 | Publish the configuration files. 22 | 23 | ```shell 24 | php artisan vendor:publish --tag=database-emails-config 25 | php artisan vendor:publish --tag=database-emails-migrations 26 | ``` 27 | 28 | Create the database table required for this package. 29 | 30 | ```shell 31 | php artisan migrate 32 | ``` 33 | 34 | Add the e-mail cronjob to your scheduler 35 | 36 | ```php 37 | protected function schedule(Schedule $schedule) 38 | { 39 | $schedule->command('email:send')->everyMinute()->withoutOverlapping(5); 40 | } 41 | ``` 42 | 43 | 44 | # Usage 45 | 46 | ### Send an email 47 | 48 | E-mails are composed the same way mailables are created. 49 | 50 | ```php 51 | use Stackkit\LaravelDatabaseEmails\Email; 52 | use Illuminate\Mail\Mailables\Content; 53 | use Stackkit\LaravelDatabaseEmails\Attachment; 54 | use Illuminate\Mail\Mailables\Envelope; 55 | 56 | Email::compose() 57 | ->content(fn (Content $content) => $content 58 | ->view('tests::dummy') 59 | ->with(['name' => 'John Doe']) 60 | ) 61 | ->envelope(fn (Envelope $envelope) => $envelope 62 | ->subject('Hello') 63 | ->from('johndoe@example.com', 'John Doe') 64 | ->to('janedoe@example.com', 'Jane Doe') 65 | ) 66 | ->attachments([ 67 | Attachment::fromStorageDisk('s3', '/invoices/john-doe/march-2024.pdf'), 68 | ]) 69 | ->send(); 70 | ]) 71 | ``` 72 | 73 | ### Sending emails to users in your application 74 | 75 | ```php 76 | Email::compose() 77 | ->user($user) 78 | ->send(); 79 | ``` 80 | 81 | By default, the `name` column will be used to set the recipient's name. If you wish to use something different, you should implement the `preferredEmailName` method in your model. 82 | 83 | ```php 84 | class User extends Model 85 | { 86 | public function preferredEmailName(): string 87 | { 88 | return $this->first_name; 89 | } 90 | } 91 | ``` 92 | 93 | By default, the `email` column will be used to set the recipient's e-mail address. If you wish to use something different, you should implement the `preferredEmailAddress` method in your model. 94 | 95 | ```php 96 | class User extends Model 97 | { 98 | public function preferredEmailAddress(): string 99 | { 100 | return $this->work_email; 101 | } 102 | } 103 | ``` 104 | 105 | By default, the app locale will be used. If you wish to use something different, you should implement the `preferredEmailLocale` method in your model. 106 | 107 | ```php 108 | class User extends Model implements HasLocalePreference 109 | { 110 | public function preferredLocale(): string 111 | { 112 | return $this->locale; 113 | } 114 | } 115 | ``` 116 | 117 | ### Using mailables 118 | 119 | You may also pass a mailable to the e-mail composer. 120 | 121 | ```php 122 | Email::compose() 123 | ->mailable(new OrderShipped()) 124 | ->send(); 125 | ``` 126 | 127 | ### Attachments 128 | 129 | To start attaching files to your e-mails, you may use the `attachments` method like you normally would in Laravel. 130 | However, you will have to use this package's `Attachment` class. 131 | 132 | 133 | ```php 134 | use Stackkit\LaravelDatabaseEmails\Attachment; 135 | 136 | Email::compose() 137 | ->attachments([ 138 | Attachment::fromPath(__DIR__.'/files/pdf-sample.pdf'), 139 | Attachment::fromPath(__DIR__.'/files/my-file.txt')->as('Test123 file'), 140 | Attachment::fromStorageDisk('my-custom-disk', 'test.txt'), 141 | ]) 142 | ->send(); 143 | ``` 144 | 145 | > [!NOTE] 146 | > `Attachment::fromData()` and `Attachment::fromStorage()` are not supported as they work with raw data. 147 | 148 | ### Attaching models to e-mails 149 | 150 | You may attach a model to an e-mail. This can be useful to attach a user or another model that belongs to the e-mail. 151 | 152 | ```php 153 | Email::compose() 154 | ->model(User::find(1)); 155 | ``` 156 | 157 | ### Scheduling 158 | 159 | You may schedule an e-mail by calling `later` instead of `send`. You must provide a Carbon instance or a strtotime valid date. 160 | 161 | ```php 162 | Email::compose() 163 | ->later('+2 hours'); 164 | ``` 165 | 166 | ### Queueing e-mails 167 | 168 | > [!IMPORTANT] 169 | > When queueing mail using the `queue` function, it is no longer necessary to schedule the `email:send` command. 170 | 171 | ```php 172 | Email::compose()->queue(); 173 | 174 | // On a specific connection 175 | Email::compose()->queue(connection: 'sqs'); 176 | 177 | // On a specific queue 178 | Email::compose()->queue(queue: 'email-queue'); 179 | 180 | // Delay (send mail in 10 minutes) 181 | Email::compose()->queue(delay: now()->addMinutes(10)); 182 | ``` 183 | 184 | If you need more flexibility, you may also pass your own job class: 185 | 186 | ```php 187 | Email::compose()->queue(jobClass: CustomSendEmailJob::class); 188 | ``` 189 | 190 | It could look like this: 191 | 192 | ```php 193 | command('model:prune', [ 239 | '--model' => [Email::class], 240 | ])->daily(); 241 | ``` 242 | 243 | By default, e-mails are pruned when they are older than 6 months. 244 | 245 | You may change that by adding the following to the AppServiceProvider.php: 246 | 247 | ```php 248 | use Stackkit\LaravelDatabaseEmails\Email; 249 | 250 | public function register(): void 251 | { 252 | Email::pruneWhen(function (Email $email) { 253 | return $email->where(...); 254 | }); 255 | } 256 | ``` 257 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # From 6.x to 7.x 2 | 3 | 7.x is a bigger change which cleans up parts of the code base and modernizes the package. That means there are a few high-impact changes. 4 | 5 | ## Database changes (Impact: High) 6 | 7 | The way addresses are stored in the database has changed. Therefore, emails created in 6.x and below are incompatible. 8 | 9 | When you upgrade, the existing database table will be renamed to "emails_old" and a new table will be created. 10 | 11 | The table migration now needs to be published first. Please run this command: 12 | 13 | ```shell 14 | php artisan vendor:publish --tag=database-emails-migrations 15 | ``` 16 | 17 | Then, run the migration: 18 | 19 | ```shell 20 | php artisan migrate 21 | ``` 22 | 23 | ## Environment variables, configurations (Impact: High) 24 | 25 | Environment variable names, as well as the config file name, have been shortened. 26 | 27 | Please publish the new configuration file: 28 | 29 | ```shell 30 | php artisan vendor:publish --tag=database-emails-config 31 | ``` 32 | 33 | You can remove the old configuration file. 34 | 35 | Rename the following environments: 36 | 37 | - `LARAVEL_DATABASE_EMAILS_TESTING_ENABLED` → `DB_EMAILS_TESTING_ENABLED` 38 | - `LARAVEL_DATABASE_EMAILS_SEND_IMMEDIATELY` → `DB_EMAILS_SEND_IMMEDIATELY` 39 | 40 | The following environments are new: 41 | 42 | - `DB_EMAILS_ATTEMPTS` 43 | - `DB_EMAILS_TESTING_EMAIL` 44 | - `DB_EMAILS_LIMIT` 45 | - `DB_EMAILS_IMMEDIATELY` 46 | 47 | The following environments have been removed: 48 | 49 | - `LARAVEL_DATABASE_EMAILS_MANUAL_MIGRATIONS` because migrations are always published. 50 | 51 | ## Creating emails (Impact: High) 52 | 53 | The way emails are composed has changed and now borrows a lot from Laravel's mailable. 54 | 55 | ```php 56 | use Illuminate\Mail\Mailables\Content; 57 | use Stackkit\LaravelDatabaseEmails\Attachment; 58 | use Stackkit\LaravelDatabaseEmails\Email; 59 | use Illuminate\Mail\Mailables\Envelope; 60 | 61 | Email::compose() 62 | ->content(fn (Content $content) => $content 63 | ->view('tests::dummy') 64 | ->with(['name' => 'John Doe']) 65 | ) 66 | ->envelope(fn (Envelope $envelope) => $envelope 67 | ->subject('Hello') 68 | ->from('johndoe@example.com', 'John Doe') 69 | ->to('janedoe@example.com', 'Jane Doe') 70 | ) 71 | ->attachments([ 72 | Attachment::fromStorageDisk('s3', '/invoices/john-doe/march-2024.pdf'), 73 | ]) 74 | ->send(); 75 | ]) 76 | ``` 77 | 78 | ## Encryption (Impact: moderate/low) 79 | 80 | E-mail encryption has been removed from the package. 81 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stackkit/laravel-database-emails", 3 | "description": "Store and send e-mails using the database", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Marick van Tuil", 8 | "email": "info@marickvantuil.nl" 9 | } 10 | ], 11 | "autoload": { 12 | "psr-4": { 13 | "Stackkit\\LaravelDatabaseEmails\\": "src/" 14 | } 15 | }, 16 | "autoload-dev": { 17 | "psr-4": { 18 | "Tests\\": "tests/", 19 | "Workbench\\App\\": "workbench/app/", 20 | "Workbench\\Database\\Factories\\": "workbench/database/factories/", 21 | "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" 22 | } 23 | }, 24 | "extra": { 25 | "laravel": { 26 | "providers": [ 27 | "Stackkit\\LaravelDatabaseEmails\\LaravelDatabaseEmailsServiceProvider" 28 | ] 29 | } 30 | }, 31 | "require": { 32 | "ext-json": "*", 33 | "laravel/framework": "^11.0|^12.0", 34 | "doctrine/dbal": "^4.0" 35 | }, 36 | "require-dev": { 37 | "mockery/mockery": "^1.2", 38 | "orchestra/testbench": "^9.0|^10.0", 39 | "nunomaduro/collision": "^8.0", 40 | "laravel/pint": "^1.14" 41 | }, 42 | "minimum-stability": "dev", 43 | "prefer-stable": true, 44 | "scripts": { 45 | "l11": [ 46 | "composer update laravel/framework:11.* orchestra/testbench:9.* nunomaduro/collision:8.* --with-all-dependencies" 47 | ], 48 | "l12": [ 49 | "composer update laravel/framework:12.* orchestra/testbench:10.* nunomaduro/collision:8.* --with-all-dependencies" 50 | ], 51 | "test": [ 52 | "testbench workbench:create-sqlite-db", 53 | "testbench package:test" 54 | ], 55 | "post-autoload-dump": [ 56 | "@clear", 57 | "@prepare" 58 | ], 59 | "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", 60 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 61 | "build": "@php vendor/bin/testbench workbench:build --ansi", 62 | "serve": [ 63 | "Composer\\Config::disableProcessTimeout", 64 | "@build", 65 | "@php vendor/bin/testbench serve" 66 | ], 67 | "lint": [ 68 | "@php vendor/bin/pint", 69 | "@php vendor/bin/phpstan analyse" 70 | ] 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /config/database-emails.php: -------------------------------------------------------------------------------- 1 | env('DB_EMAILS_ATTEMPTS', 3), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Test E-mail 21 | |-------------------------------------------------------------------------- 22 | | 23 | | When developing your application or testing on a staging server you may 24 | | wish to send all e-mails to a specific test inbox. Once enabled, every 25 | | newly created e-mail will be sent to the specified test address. 26 | | 27 | */ 28 | 29 | 'testing' => [ 30 | 31 | 'email' => env('DB_EMAILS_TESTING_EMAIL'), 32 | 33 | 'enabled' => env('DB_EMAILS_TESTING_ENABLED', false), 34 | 35 | ], 36 | 37 | /* 38 | |-------------------------------------------------------------------------- 39 | | Cronjob Limit 40 | |-------------------------------------------------------------------------- 41 | | 42 | | Limit the number of e-mails that should be sent at a time. Please adjust this 43 | | configuration based on the number of e-mails you expect to send and 44 | | the throughput of your e-mail sending provider. 45 | | 46 | */ 47 | 48 | 'limit' => env('DB_EMAILS_LIMIT', 20), 49 | 50 | /* 51 | |-------------------------------------------------------------------------- 52 | | Send E-mails Immediately 53 | |-------------------------------------------------------------------------- 54 | | 55 | | Sends e-mails immediately after calling send() or schedule(). Useful for development 56 | | when you don't have Laravel Scheduler running or don't want to wait up to 57 | | 60 seconds for each e-mail to be sent. 58 | | 59 | */ 60 | 61 | 'immediately' => env('DB_EMAILS_IMMEDIATELY', false), 62 | ]; 63 | -------------------------------------------------------------------------------- /database/migrations/2024_03_16_151608_create_new_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 22 | $table->string('label')->nullable(); 23 | $table->json('recipient'); 24 | $table->json('cc')->nullable(); 25 | $table->json('bcc')->nullable(); 26 | $table->string('subject'); 27 | $table->string('view'); 28 | $table->json('variables')->nullable(); 29 | $table->text('body'); 30 | $table->integer('attempts')->default(0); 31 | $table->boolean('sending')->default(0); 32 | $table->boolean('failed')->default(0); 33 | $table->text('error')->nullable(); 34 | $table->json('attachments')->nullable(); 35 | $table->json('from')->nullable(); 36 | $table->nullableMorphs('model'); 37 | $table->json('reply_to')->nullable(); 38 | $table->timestamp('queued_at')->nullable(); 39 | $table->timestamp('scheduled_at')->nullable(); 40 | $table->timestamp('sent_at')->nullable()->index(); 41 | $table->timestamp('delivered_at')->nullable(); 42 | $table->timestamps(); 43 | $table->softDeletes(); 44 | }); 45 | } 46 | 47 | /** 48 | * Reverse the migrations. 49 | * 50 | * @return void 51 | */ 52 | public function down() 53 | { 54 | // 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | image: serversideup/php:8.3-fpm 4 | volumes: 5 | - .:/var/www/html 6 | mysql: 7 | image: mysql:8 8 | ports: 9 | - '${MYSQL_PORT:-3307}:3306' 10 | environment: 11 | MYSQL_USER: 'test' 12 | MYSQL_PASSWORD: 'test' 13 | MYSQL_DATABASE: 'test' 14 | MYSQL_RANDOM_ROOT_PASSWORD: true 15 | pgsql: 16 | image: postgres:14 17 | ports: 18 | - '${POSTGRES_PORT:-5432}:5432' 19 | environment: 20 | POSTGRES_USER: 'test' 21 | POSTGRES_PASSWORD: 'test' 22 | POSTGRES_DB: 'test' 23 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests/ 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | src/ 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/Attachment.php: -------------------------------------------------------------------------------- 1 | as = $name; 43 | 44 | return $this; 45 | } 46 | 47 | public function withMime(string $mime): self 48 | { 49 | $this->mime = $mime; 50 | 51 | return $this; 52 | } 53 | 54 | public function toArray(): array 55 | { 56 | return [ 57 | 'path' => $this->path, 58 | 'disk' => $this->disk, 59 | 'as' => $this->as, 60 | 'mime' => $this->mime, 61 | ]; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | 'boolean', 43 | 'recipient' => 'json', 44 | 'from' => 'json', 45 | 'cc' => 'json', 46 | 'bcc' => 'json', 47 | 'reply_to' => 'json', 48 | 'variables' => 'json', 49 | 'attachments' => 'json', 50 | ]; 51 | 52 | protected $table = 'emails'; 53 | 54 | protected $guarded = []; 55 | 56 | public static ?Closure $pruneQuery = null; 57 | 58 | public static function compose(): EmailComposer 59 | { 60 | return new EmailComposer(new static); 61 | } 62 | 63 | public function isSent(): bool 64 | { 65 | return ! is_null($this->sent_at); 66 | } 67 | 68 | public function hasFailed(): bool 69 | { 70 | return $this->failed == 1; 71 | } 72 | 73 | public function markAsSending(): void 74 | { 75 | $this->update([ 76 | 'attempts' => $this->attempts + 1, 77 | 'sending' => 1, 78 | ]); 79 | } 80 | 81 | public function markAsSent(): void 82 | { 83 | $this->update([ 84 | 'sending' => 0, 85 | 'sent_at' => now(), 86 | 'failed' => 0, 87 | 'error' => '', 88 | ]); 89 | } 90 | 91 | public function markAsFailed(Throwable $exception): void 92 | { 93 | $this->update([ 94 | 'sending' => 0, 95 | 'failed' => 1, 96 | 'error' => (string) $exception, 97 | ]); 98 | } 99 | 100 | public function send(): void 101 | { 102 | (new Sender)->send($this); 103 | } 104 | 105 | public static function pruneWhen(Closure $closure): void 106 | { 107 | static::$pruneQuery = $closure; 108 | } 109 | 110 | public function prunable(): Builder 111 | { 112 | if (static::$pruneQuery) { 113 | return (static::$pruneQuery)($this); 114 | } 115 | 116 | return $this->where('created_at', '<', now()->subMonths(6)); 117 | } 118 | 119 | public function model(): MorphTo 120 | { 121 | return $this->morphTo(); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/EmailComposer.php: -------------------------------------------------------------------------------- 1 | envelope = $envelope($this->envelope ?: new Envelope); 49 | 50 | return $this; 51 | } 52 | 53 | $this->envelope = $envelope; 54 | 55 | return $this; 56 | } 57 | 58 | public function content(null|Content|Closure $content = null): self 59 | { 60 | if ($content instanceof Closure) { 61 | $this->content = $content($this->content ?: new Content); 62 | 63 | return $this; 64 | } 65 | 66 | $this->content = $content; 67 | 68 | return $this; 69 | } 70 | 71 | public function attachments(null|array|Closure $attachments = null): self 72 | { 73 | if ($attachments instanceof Closure) { 74 | $this->attachments = $attachments($this->attachments ?: []); 75 | 76 | return $this; 77 | } 78 | 79 | $this->attachments = $attachments; 80 | 81 | return $this; 82 | } 83 | 84 | public function user(User $user) 85 | { 86 | return $this->envelope(function (Envelope $envelope) use ($user) { 87 | $name = method_exists($user, 'preferredEmailName') 88 | ? $user->preferredEmailName() 89 | : ($user->name ?? null); 90 | 91 | $email = method_exists($user, 'preferredEmailAddress') 92 | ? $user->preferredEmailAddress() 93 | : $user->email; 94 | 95 | if ($user instanceof HasLocalePreference) { 96 | $this->locale = $user->preferredLocale(); 97 | } 98 | 99 | return $envelope->to($email, $name); 100 | }); 101 | } 102 | 103 | public function model(Model $model) 104 | { 105 | $this->model = $model; 106 | 107 | return $this; 108 | } 109 | 110 | public function label(string $label): self 111 | { 112 | $this->email->label = $label; 113 | 114 | return $this; 115 | } 116 | 117 | public function later($scheduledAt): Email 118 | { 119 | $this->email->scheduled_at = Carbon::parse($scheduledAt); 120 | 121 | return $this->send(); 122 | } 123 | 124 | public function queue(?string $connection = null, ?string $queue = null, $delay = null, ?string $jobClass = null): Email 125 | { 126 | $connection = $connection ?: config('queue.default'); 127 | $queue = $queue ?: 'default'; 128 | 129 | $this->email->queued_at = now(); 130 | 131 | $this->queued = true; 132 | $this->connection = $connection; 133 | $this->queue = $queue; 134 | $this->delay = $delay; 135 | $this->jobClass = $jobClass; 136 | 137 | return $this->send(); 138 | } 139 | 140 | public function mailable(Mailable $mailable): self 141 | { 142 | $this->mailable = $mailable; 143 | 144 | (new MailableReader)->read($this); 145 | 146 | return $this; 147 | } 148 | 149 | public function send(): Email 150 | { 151 | if ($this->envelope && $this->content) { 152 | (new MailableReader)->read($this); 153 | } 154 | 155 | if (! is_array($this->email->from)) { 156 | $this->email->from = []; 157 | } 158 | 159 | $this->email->from = [ 160 | 'name' => $this->email->from['name'] ?? config('mail.from.name'), 161 | 'address' => $this->email->from['address'] ?? config('mail.from.address'), 162 | ]; 163 | 164 | $this->email->save(); 165 | 166 | $this->email->refresh(); 167 | 168 | if (Config::sendImmediately()) { 169 | $this->email->send(); 170 | 171 | return $this->email; 172 | } 173 | 174 | if ($this->queued) { 175 | $job = $this->jobClass ?: SendEmailJob::class; 176 | 177 | dispatch(new $job($this->email)) 178 | ->onConnection($this->connection) 179 | ->onQueue($this->queue) 180 | ->delay($this->delay); 181 | 182 | return $this->email; 183 | } 184 | 185 | return $this->email; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/LaravelDatabaseEmailsServiceProvider.php: -------------------------------------------------------------------------------- 1 | bootConfig(); 17 | $this->bootDatabase(); 18 | } 19 | 20 | /** 21 | * Boot the config for the package. 22 | */ 23 | private function bootConfig(): void 24 | { 25 | $baseDir = __DIR__.DIRECTORY_SEPARATOR.'..'.DIRECTORY_SEPARATOR; 26 | $configDir = $baseDir.'config'.DIRECTORY_SEPARATOR; 27 | 28 | $this->publishes([ 29 | $configDir.'laravel-database-emails.php' => config_path('laravel-database-emails.php'), 30 | ], 'database-emails-config'); 31 | } 32 | 33 | /** 34 | * Boot the database for the package. 35 | */ 36 | private function bootDatabase(): void 37 | { 38 | $baseDir = __DIR__.DIRECTORY_SEPARATOR.'..'.DIRECTORY_SEPARATOR; 39 | $migrationsDir = $baseDir.'database'.DIRECTORY_SEPARATOR.'migrations'.DIRECTORY_SEPARATOR; 40 | 41 | $this->publishes([ 42 | $migrationsDir => "{$this->app->databasePath()}/migrations", 43 | ], 'database-emails-migrations'); 44 | } 45 | 46 | /** 47 | * Register the service provider. 48 | */ 49 | public function register(): void 50 | { 51 | $this->commands([ 52 | SendEmailsCommand::class, 53 | ]); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/MailableReader.php: -------------------------------------------------------------------------------- 1 | mailable || ($composer->envelope && $composer->content); 25 | 26 | if (! $canBeSent) { 27 | throw new Error('E-mail cannot be sent: no mailable or envelope and content provided.'); 28 | } 29 | 30 | if ($composer->envelope && $composer->content) { 31 | $composer->mailable = new class($composer) extends Mailable 32 | { 33 | public function __construct(private EmailComposer $composer) 34 | { 35 | // 36 | } 37 | 38 | public function content(): Content 39 | { 40 | return $this->composer->content; 41 | } 42 | 43 | public function envelope(): Envelope 44 | { 45 | return $this->composer->envelope; 46 | } 47 | 48 | public function attachments(): array 49 | { 50 | return $this->composer->attachments ?? []; 51 | } 52 | }; 53 | } 54 | 55 | (fn (Mailable $mailable) => $mailable->prepareMailableForDelivery())->call( 56 | $composer->mailable, 57 | $composer->mailable, 58 | ); 59 | 60 | $this->readRecipient($composer); 61 | 62 | $this->readFrom($composer); 63 | 64 | $this->readCc($composer); 65 | 66 | $this->readBcc($composer); 67 | 68 | $this->readReplyTo($composer); 69 | 70 | $this->readSubject($composer); 71 | 72 | $this->readBody($composer); 73 | 74 | $this->readAttachments($composer); 75 | 76 | $this->readModel($composer); 77 | } 78 | 79 | /** 80 | * Convert the mailable addresses array into a array with only e-mails. 81 | * 82 | * @param string $from 83 | */ 84 | private function convertMailableAddresses($from): array 85 | { 86 | return collect($from)->mapWithKeys(function ($recipient) { 87 | return [$recipient['address'] => $recipient['name']]; 88 | })->toArray(); 89 | } 90 | 91 | /** 92 | * Read the mailable recipient to the email composer. 93 | */ 94 | private function readRecipient(EmailComposer $composer): void 95 | { 96 | if (config('database-emails.testing.enabled')) { 97 | $composer->email->recipient = [ 98 | config('database-emails.testing.email') => null, 99 | ]; 100 | 101 | return; 102 | } 103 | 104 | $composer->email->recipient = $this->prepareAddressForDatabaseStorage( 105 | $composer->mailable->to); 106 | } 107 | 108 | /** 109 | * Read the mailable from field to the email composer. 110 | */ 111 | private function readFrom(EmailComposer $composer): void 112 | { 113 | $composer->email->from = head($composer->mailable->from); 114 | } 115 | 116 | /** 117 | * Read the mailable cc to the email composer. 118 | */ 119 | private function readCc(EmailComposer $composer): void 120 | { 121 | $composer->email->cc = $this->prepareAddressForDatabaseStorage( 122 | $composer->mailable->cc); 123 | } 124 | 125 | /** 126 | * Read the mailable bcc to the email composer. 127 | */ 128 | private function readBcc(EmailComposer $composer): void 129 | { 130 | $composer->email->bcc = $this->prepareAddressForDatabaseStorage( 131 | $composer->mailable->bcc); 132 | } 133 | 134 | /** 135 | * Read the mailable reply-to to the email composer. 136 | */ 137 | private function readReplyTo(EmailComposer $composer): void 138 | { 139 | $composer->email->reply_to = $this->prepareAddressForDatabaseStorage( 140 | $composer->mailable->replyTo); 141 | } 142 | 143 | /** 144 | * Read the mailable subject to the email composer. 145 | */ 146 | private function readSubject(EmailComposer $composer): void 147 | { 148 | $composer->email->subject = $composer->mailable->subject; 149 | } 150 | 151 | /** 152 | * Read the mailable body to the email composer. 153 | * 154 | * @throws Exception 155 | */ 156 | private function readBody(EmailComposer $composer): void 157 | { 158 | /** @var Mailable $mailable */ 159 | $mailable = $composer->mailable; 160 | 161 | $composer->email->view = $mailable->view; 162 | $composer->email->variables = Arr::except($mailable->buildViewData(), [ 163 | '__laravel_mailable', 164 | ]); 165 | 166 | $localeToUse = $composer->locale ?? app()->currentLocale(); 167 | 168 | $this->withLocale( 169 | $localeToUse, 170 | fn () => $composer->email->body = view($mailable->view, $mailable->buildViewData())->render(), 171 | ); 172 | } 173 | 174 | /** 175 | * Read the mailable attachments to the email composer. 176 | */ 177 | private function readAttachments(EmailComposer $composer): void 178 | { 179 | $mailable = $composer->mailable; 180 | 181 | $composer->email->attachments = array_map(function (array $attachment) { 182 | if (! $attachment['file'] instanceof Attachment) { 183 | throw new Error('The attachment is not an instance of '.Attachment::class.'.'); 184 | } 185 | 186 | return $attachment['file']->toArray(); 187 | }, $mailable->attachments); 188 | } 189 | 190 | public function readModel(EmailComposer $composer): void 191 | { 192 | if ($composer->model) { 193 | $composer->email->model()->associate($composer->model); 194 | } 195 | } 196 | 197 | private function prepareAddressForDatabaseStorage(array $addresses): array 198 | { 199 | return collect($addresses)->mapWithKeys(function ($recipient) { 200 | return [$recipient['address'] => $recipient['name']]; 201 | })->toArray(); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/MessageSent.php: -------------------------------------------------------------------------------- 1 | message = $message; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/SendEmailJob.php: -------------------------------------------------------------------------------- 1 | email = $email; 25 | } 26 | 27 | public function handle(): void 28 | { 29 | $this->email->send(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/SendEmailsCommand.php: -------------------------------------------------------------------------------- 1 | getQueue(); 19 | 20 | if ($emails->isEmpty()) { 21 | $this->components->info('There is nothing to send.'); 22 | 23 | return; 24 | } 25 | 26 | $this->components->info('Sending '.count($emails).' e-mail(s).'); 27 | 28 | foreach ($emails as $email) { 29 | $recipients = implode(', ', array_keys($email->recipient)); 30 | $line = str($email->subject)->limit(40).' - '.str($recipients)->limit(40); 31 | 32 | rescue(function () use ($email, $line) { 33 | $email->send(); 34 | 35 | $this->components->twoColumnDetail($line, 'DONE'); 36 | }, function (Throwable $e) use ($email, $line) { 37 | $email->markAsFailed($e); 38 | 39 | $this->components->twoColumnDetail($line, 'FAIL'); 40 | }); 41 | } 42 | 43 | $this->newLine(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Sender.php: -------------------------------------------------------------------------------- 1 | isSent()) { 16 | return; 17 | } 18 | 19 | $email->markAsSending(); 20 | 21 | $sentMessage = Mail::send([], [], function (Message $message) use ($email) { 22 | $this->buildMessage($message, $email); 23 | }); 24 | 25 | event(new MessageSent($sentMessage)); 26 | 27 | $email->markAsSent(); 28 | } 29 | 30 | private function buildMessage(Message $message, Email $email): void 31 | { 32 | $message->to($email->recipient) 33 | ->cc($email->cc ?: []) 34 | ->bcc($email->bcc ?: []) 35 | ->replyTo($email->reply_to ?: []) 36 | ->subject($email->subject) 37 | ->from($email->from['address'], $email->from['name']) 38 | ->html($email->body); 39 | 40 | foreach ($email->attachments as $dbAttachment) { 41 | $attachment = match (true) { 42 | isset($dbAttachment['disk']) => Attachment::fromStorageDisk( 43 | $dbAttachment['disk'], 44 | $dbAttachment['path'] 45 | ), 46 | default => Attachment::fromPath($dbAttachment['path']), 47 | }; 48 | 49 | if (! empty($dbAttachment['mime'])) { 50 | $attachment->withMime($dbAttachment['mime']); 51 | } 52 | 53 | if (! empty($dbAttachment['as'])) { 54 | $attachment->as($dbAttachment['as']); 55 | } 56 | 57 | $message->attach($attachment); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/SentMessage.php: -------------------------------------------------------------------------------- 1 | getFrom() as $address) { 35 | $sentMessage->from[$address->getAddress()] = $address->getName(); 36 | } 37 | 38 | foreach ($email->getTo() as $address) { 39 | $sentMessage->to[$address->getAddress()] = $address->getName(); 40 | } 41 | 42 | foreach ($email->getCc() as $address) { 43 | $sentMessage->cc[$address->getAddress()] = $address->getName(); 44 | } 45 | 46 | foreach ($email->getBcc() as $address) { 47 | $sentMessage->bcc[$address->getAddress()] = $address->getName(); 48 | } 49 | 50 | foreach ($email->getReplyTo() as $address) { 51 | $sentMessage->replyTo[$address->getAddress()] = $address->getName(); 52 | } 53 | 54 | $sentMessage->subject = $email->getSubject(); 55 | $sentMessage->body = $email->getHtmlBody(); 56 | $sentMessage->attachments = array_map(function (DataPart $dataPart) { 57 | return [ 58 | 'body' => $dataPart->getBody(), 59 | 'disposition' => $dataPart->asDebugString(), 60 | ]; 61 | }, $email->getAttachments()); 62 | 63 | return $sentMessage; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Store.php: -------------------------------------------------------------------------------- 1 | whereNull('deleted_at') 24 | ->whereNull('sent_at') 25 | ->whereNull('queued_at') 26 | ->where(function ($query) { 27 | $query->whereNull('scheduled_at') 28 | ->orWhere('scheduled_at', '<=', Carbon::now()->toDateTimeString()); 29 | }) 30 | ->where('sending', '=', 0) 31 | ->where('attempts', '<', Config::maxAttemptCount()) 32 | ->orderBy('created_at', 'asc') 33 | ->limit(Config::cronjobEmailLimit()) 34 | ->cursor(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /testbench.yaml: -------------------------------------------------------------------------------- 1 | providers: 2 | - Workbench\App\Providers\WorkbenchServiceProvider 3 | - Stackkit\LaravelDatabaseEmails\LaravelDatabaseEmailsServiceProvider 4 | 5 | migrations: 6 | - workbench/database/migrations 7 | 8 | seeders: 9 | - Workbench\Database\Seeders\DatabaseSeeder 10 | 11 | workbench: 12 | start: '/' 13 | install: true 14 | health: false 15 | discovers: 16 | web: true 17 | api: false 18 | commands: false 19 | components: false 20 | views: true 21 | build: [] 22 | assets: [] 23 | sync: [] 24 | -------------------------------------------------------------------------------- /tests/ComposeTest.php: -------------------------------------------------------------------------------- 1 | 'John Doe', 20 | 'email' => 'johndoe@example.com', 21 | 'password' => 'secret', 22 | ]); 23 | 24 | $email = Email::compose() 25 | ->user($user) 26 | ->model($user) 27 | ->envelope(fn (Envelope $envelope) => $envelope->subject('Hey')) 28 | ->content(fn (Content $content) => $content->view('tests::welcome')) 29 | ->send(); 30 | 31 | $this->assertEquals($email->model_type, $user->getMorphClass()); 32 | $this->assertEquals($email->model_id, $user->getKey()); 33 | } 34 | 35 | #[Test] 36 | public function models_can_be_empty(): void 37 | { 38 | $user = User::forceCreate([ 39 | 'name' => 'John Doe', 40 | 'email' => 'johndoe@example.com', 41 | 'password' => 'secret', 42 | ]); 43 | 44 | $email = Email::compose() 45 | ->user($user) 46 | ->envelope(fn (Envelope $envelope) => $envelope->subject('Hey')) 47 | ->content(fn (Content $content) => $content->view('tests::welcome')) 48 | ->send(); 49 | 50 | $this->assertNull($email->model_type); 51 | $this->assertNull($email->model_id); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/ConfigCacheTest.php: -------------------------------------------------------------------------------- 1 | fail('Configuration file cannot be serialized'); 23 | } else { 24 | $this->assertTrue(true); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/ConfigTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(3, Config::maxAttemptCount()); 14 | 15 | $this->app['config']->set('database-emails.attempts', 5); 16 | 17 | $this->assertEquals(5, Config::maxAttemptCount()); 18 | } 19 | 20 | #[Test] 21 | public function test_testing() 22 | { 23 | $this->assertFalse(Config::testing()); 24 | 25 | $this->app['config']->set('database-emails.testing.enabled', true); 26 | 27 | $this->assertTrue(Config::testing()); 28 | } 29 | 30 | #[Test] 31 | public function test_test_email_address() 32 | { 33 | $this->assertEquals('test@email.com', Config::testEmailAddress()); 34 | 35 | $this->app['config']->set('database-emails.testing.email', 'test+update@email.com'); 36 | 37 | $this->assertEquals('test+update@email.com', Config::testEmailAddress()); 38 | } 39 | 40 | #[Test] 41 | public function test_cronjob_email_limit() 42 | { 43 | $this->assertEquals(20, Config::cronjobEmailLimit()); 44 | 45 | $this->app['config']->set('database-emails.limit', 15); 46 | 47 | $this->assertEquals(15, Config::cronjobEmailLimit()); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/DatabaseInteractionTest.php: -------------------------------------------------------------------------------- 1 | sendEmail(['label' => 'welcome-email']); 17 | 18 | $this->assertEquals('welcome-email', DB::table('emails')->find(1)->label); 19 | $this->assertEquals('welcome-email', $email->label); 20 | } 21 | 22 | #[Test] 23 | public function recipient_should_be_saved_correctly() 24 | { 25 | $email = $this->sendEmail(['recipient' => 'john@doe.com']); 26 | 27 | $this->assertEquals(['john@doe.com' => null], $email->recipient); 28 | } 29 | 30 | #[Test] 31 | public function cc_and_bcc_should_be_saved_correctly() 32 | { 33 | $email = $this->sendEmail([ 34 | 'cc' => $cc = [ 35 | 'john@doe.com', 36 | ], 37 | 'bcc' => $bcc = [ 38 | 'jane@doe.com', 39 | ], 40 | ]); 41 | 42 | $this->assertEquals(['john@doe.com' => null], $email->cc); 43 | $this->assertEquals(['jane@doe.com' => null], $email->bcc); 44 | } 45 | 46 | #[Test] 47 | public function reply_to_should_be_saved_correctly() 48 | { 49 | $email = $this->sendEmail([ 50 | 'reply_to' => [ 51 | 'john@doe.com', 52 | ], 53 | ]); 54 | 55 | $this->assertEquals(['john@doe.com' => null], $email->reply_to); 56 | } 57 | 58 | #[Test] 59 | public function subject_should_be_saved_correclty() 60 | { 61 | $email = $this->sendEmail(['subject' => 'test subject']); 62 | 63 | $this->assertEquals('test subject', DB::table('emails')->find(1)->subject); 64 | $this->assertEquals('test subject', $email->subject); 65 | } 66 | 67 | #[Test] 68 | public function view_should_be_saved_correctly() 69 | { 70 | $email = $this->sendEmail(['view' => 'tests::dummy']); 71 | 72 | $this->assertEquals('tests::dummy', DB::table('emails')->find(1)->view); 73 | $this->assertEquals('tests::dummy', $email->view); 74 | } 75 | 76 | #[Test] 77 | public function scheduled_date_should_be_saved_correctly() 78 | { 79 | $email = $this->sendEmail(); 80 | $this->assertNull(DB::table('emails')->find(1)->scheduled_at); 81 | $this->assertNull($email->scheduled_at); 82 | 83 | Carbon::setTestNow(Carbon::create(2019, 1, 1, 1, 2, 3)); 84 | $email = $this->scheduleEmail('+2 weeks'); 85 | $this->assertNotNull(DB::table('emails')->find(2)->scheduled_at); 86 | $this->assertEquals('2019-01-15 01:02:03', $email->scheduled_at); 87 | } 88 | 89 | #[Test] 90 | public function the_body_should_be_saved_correctly() 91 | { 92 | $email = $this->sendEmail(['variables' => ['name' => 'Jane Doe']]); 93 | 94 | $expectedBody = "Name: Jane Doe\n"; 95 | 96 | $this->assertSame($expectedBody, DB::table('emails')->find(1)->body); 97 | $this->assertSame($expectedBody, $email->body); 98 | } 99 | 100 | #[Test] 101 | public function from_should_be_saved_correctly() 102 | { 103 | $email = $this->composeEmail()->send(); 104 | 105 | $this->assertEquals($email->from['address'], $email->from['address']); 106 | $this->assertEquals($email->from['name'], $email->from['name']); 107 | 108 | $email = $this->composeEmail([ 109 | 'from' => new Address('marick@dolphiq.nl', 'Marick'), 110 | ])->send(); 111 | 112 | $this->assertTrue((bool) $email->from); 113 | $this->assertEquals('marick@dolphiq.nl', $email->from['address']); 114 | $this->assertEquals('Marick', $email->from['name']); 115 | } 116 | 117 | #[Test] 118 | public function variables_should_be_saved_correctly() 119 | { 120 | $email = $this->sendEmail(['variables' => ['name' => 'John Doe']]); 121 | 122 | $this->assertEquals(['name' => 'John Doe'], $email->variables); 123 | } 124 | 125 | #[Test] 126 | public function the_sent_date_should_be_null() 127 | { 128 | $email = $this->sendEmail(); 129 | 130 | $this->assertNull(DB::table('emails')->find(1)->sent_at); 131 | $this->assertNull($email->sent_at); 132 | } 133 | 134 | #[Test] 135 | public function failed_should_be_zero() 136 | { 137 | $email = $this->sendEmail(); 138 | 139 | $this->assertEquals(0, DB::table('emails')->find(1)->failed); 140 | $this->assertFalse($email->hasFailed()); 141 | } 142 | 143 | #[Test] 144 | public function attempts_should_be_zero() 145 | { 146 | $email = $this->sendEmail(); 147 | 148 | $this->assertEquals(0, DB::table('emails')->find(1)->attempts); 149 | $this->assertEquals(0, $email->attempts); 150 | } 151 | 152 | #[Test] 153 | public function the_scheduled_date_should_be_saved_correctly() 154 | { 155 | Carbon::setTestNow(Carbon::now()); 156 | 157 | $scheduledFor = date('Y-m-d H:i:s', Carbon::now()->addWeek(2)->timestamp); 158 | 159 | $email = $this->scheduleEmail('+2 weeks'); 160 | 161 | $this->assertEquals($scheduledFor, $email->scheduled_at); 162 | } 163 | 164 | #[Test] 165 | public function recipient_should_be_swapped_for_test_address_when_in_testing_mode() 166 | { 167 | $this->app['config']->set('database-emails.testing.enabled', function () { 168 | return true; 169 | }); 170 | $this->app['config']->set('database-emails.testing.email', 'test@address.com'); 171 | 172 | $email = $this->sendEmail(['recipient' => 'jane@doe.com']); 173 | 174 | $this->assertEquals(['test@address.com' => null], $email->recipient); 175 | } 176 | 177 | #[Test] 178 | public function attachments_should_be_saved_correctly() 179 | { 180 | $email = $this->composeEmail() 181 | ->attachments([ 182 | Attachment::fromPath(__DIR__.'/files/pdf-sample.pdf'), 183 | Attachment::fromPath(__DIR__.'/files/pdf-sample2.pdf'), 184 | Attachment::fromStorageDisk('my-custom-disk', 'pdf-sample-2.pdf'), 185 | ]) 186 | ->send(); 187 | 188 | $this->assertCount(3, $email->attachments); 189 | 190 | $this->assertEquals( 191 | [ 192 | 'path' => __DIR__.'/files/pdf-sample.pdf', 193 | 'disk' => null, 194 | 'as' => null, 195 | 'mime' => null, 196 | ], 197 | $email->attachments[0] 198 | ); 199 | } 200 | 201 | #[Test] 202 | public function in_memory_attachments_are_not_supported() 203 | { 204 | $this->expectExceptionMessage('Raw attachments are not supported in the database email driver.'); 205 | 206 | $this->composeEmail() 207 | ->attachments([ 208 | Attachment::fromData(fn () => file_get_contents(__DIR__.'/files/pdf-sample.pdf'), 'pdf-sample'), 209 | ]) 210 | ->send(); 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /tests/EnvelopeTest.php: -------------------------------------------------------------------------------- 1 | envelope( 22 | (new Envelope) 23 | ->subject('Hey') 24 | ->from('asdf@gmail.com') 25 | ->to(['johndoe@example.com', 'janedoe@example.com']) 26 | ) 27 | ->content( 28 | (new Content) 29 | ->view('tests::dummy') 30 | ->with(['name' => 'John Doe']) 31 | ) 32 | ->send(); 33 | 34 | $this->assertEquals([ 35 | 'johndoe@example.com' => null, 36 | 'janedoe@example.com' => null, 37 | ], $email->recipient); 38 | } 39 | 40 | #[Test] 41 | public function test_it_can_pass_user_models() 42 | { 43 | $user = (new User)->forceFill([ 44 | 'email' => 'johndoe@example.com', 45 | 'name' => 'J. Doe', 46 | ]); 47 | 48 | $email = Email::compose() 49 | ->user($user) 50 | ->envelope(fn (Envelope $envelope) => $envelope->subject('Hey')) 51 | ->content(fn (Content $content) => $content->view('tests::welcome')) 52 | ->send(); 53 | 54 | $this->assertEquals( 55 | [ 56 | 'johndoe@example.com' => 'J. Doe', 57 | ], 58 | $email->recipient 59 | ); 60 | } 61 | 62 | #[Test] 63 | public function users_can_have_a_preferred_email() 64 | { 65 | $user = (new UserWithPreferredEmail)->forceFill([ 66 | 'email' => 'johndoe@example.com', 67 | 'name' => 'J. Doe', 68 | ]); 69 | 70 | $email = Email::compose() 71 | ->user($user) 72 | ->envelope(fn (Envelope $envelope) => $envelope->subject('Hey')) 73 | ->content(fn (Content $content) => $content->view('tests::welcome')) 74 | ->send(); 75 | 76 | $this->assertEquals( 77 | [ 78 | 'noreply@abc.com' => 'J. Doe', 79 | ], 80 | $email->recipient 81 | ); 82 | } 83 | 84 | #[Test] 85 | public function users_can_have_a_preferred_name() 86 | { 87 | $user = (new UserWithPreferredName)->forceFill([ 88 | 'email' => 'johndoe@example.com', 89 | 'name' => 'J. Doe', 90 | ]); 91 | 92 | $email = Email::compose() 93 | ->user($user) 94 | ->envelope(fn (Envelope $envelope) => $envelope->subject('Hey')) 95 | ->content(fn (Content $content) => $content->view('tests::welcome')) 96 | ->send(); 97 | 98 | $this->assertEquals( 99 | [ 100 | 'johndoe@example.com' => 'J.D.', 101 | ], 102 | $email->recipient 103 | ); 104 | } 105 | 106 | #[Test] 107 | public function users_can_have_a_preferred_locale() 108 | { 109 | $nonLocaleUser = (new User)->forceFill([ 110 | 'email' => 'johndoe@example.com', 111 | 'name' => 'J. Doe', 112 | ]); 113 | 114 | $emailForNonLocaleUser = Email::compose() 115 | ->user($nonLocaleUser) 116 | ->envelope(fn (Envelope $envelope) => $envelope->subject('Hey')) 117 | ->content(fn (Content $content) => $content->view('locale-email')) 118 | ->send(); 119 | 120 | $localeUser = (new UserWithPreferredLocale)->forceFill([ 121 | 'email' => 'johndoe@example.com', 122 | 'name' => 'J. Doe', 123 | ]); 124 | 125 | $emailForLocaleUser = Email::compose() 126 | ->user($localeUser) 127 | ->envelope(fn (Envelope $envelope) => $envelope->subject('Hey')) 128 | ->content(fn (Content $content) => $content->view('locale-email')) 129 | ->send(); 130 | 131 | $this->assertStringContainsString('Hello!', $emailForNonLocaleUser->body); 132 | $this->assertStringContainsString('Kumusta!', $emailForLocaleUser->body); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /tests/MailableReaderTest.php: -------------------------------------------------------------------------------- 1 | mailable($this->mailable()); 25 | 26 | $this->assertEquals(['john@doe.com' => 'John Doe'], $composer->email->recipient); 27 | 28 | $composer = Email::compose() 29 | ->mailable( 30 | $this->mailable()->to(['jane@doe.com']) 31 | ); 32 | 33 | $this->assertCount(2, $composer->email->recipient); 34 | $this->assertArrayHasKey('john@doe.com', $composer->email->recipient); 35 | $this->assertArrayHasKey('jane@doe.com', $composer->email->recipient); 36 | } 37 | 38 | #[Test] 39 | public function it_extracts_cc_addresses() 40 | { 41 | $composer = Email::compose()->mailable($this->mailable()); 42 | 43 | $this->assertEquals(['john+cc@doe.com' => null, 'john+cc2@doe.com' => null], $composer->email->cc); 44 | } 45 | 46 | #[Test] 47 | public function it_extracts_bcc_addresses() 48 | { 49 | $composer = Email::compose()->mailable($this->mailable()); 50 | 51 | $this->assertEquals(['john+bcc@doe.com' => null, 'john+bcc2@doe.com' => null], $composer->email->bcc); 52 | } 53 | 54 | #[Test] 55 | public function it_extracts_reply_to_addresses() 56 | { 57 | $composer = Email::compose()->mailable($this->mailable()); 58 | 59 | $this->assertEquals(['replyto@example.com' => null, 'replyto2@example.com' => null], $composer->email->reply_to); 60 | } 61 | 62 | #[Test] 63 | public function it_extracts_the_subject() 64 | { 65 | $composer = Email::compose()->mailable($this->mailable()); 66 | 67 | $this->assertEquals('Your order has shipped!', $composer->email->subject); 68 | } 69 | 70 | #[Test] 71 | public function it_extracts_the_body() 72 | { 73 | $composer = Email::compose()->mailable($this->mailable()); 74 | 75 | $this->assertEquals("Name: John Doe\n", $composer->email->body); 76 | } 77 | 78 | #[Test] 79 | public function it_extracts_attachments() 80 | { 81 | $email = Email::compose()->mailable($this->mailable())->send(); 82 | 83 | $attachments = $email->attachments; 84 | 85 | $this->assertCount(2, $attachments); 86 | 87 | $this->assertEquals(__DIR__.'/files/pdf-sample.pdf', $attachments[0]['path']); 88 | } 89 | 90 | #[Test] 91 | public function it_extracts_the_from_address_and_or_name() 92 | { 93 | $email = Email::compose()->mailable( 94 | ($this->mailable()) 95 | ->from('marick@dolphiq.nl', 'Marick') 96 | )->send(); 97 | 98 | $this->assertTrue((bool) $email->from); 99 | $this->assertEquals('marick@dolphiq.nl', $email->from['address']); 100 | $this->assertEquals('Marick', $email->from['name']); 101 | 102 | $email = Email::compose()->mailable( 103 | ($this->mailable()) 104 | ->from('marick@dolphiq.nl') 105 | )->send(); 106 | 107 | $this->assertTrue((bool) $email->from); 108 | $this->assertEquals('marick@dolphiq.nl', $email->from['address']); 109 | $this->assertEquals('Laravel', $email->from['name']); 110 | 111 | $email = Email::compose()->mailable( 112 | ($this->mailable()) 113 | ->from('marick@dolphiq.nl', 'Marick') 114 | )->send(); 115 | 116 | $this->assertEquals('marick@dolphiq.nl', $email->from['address']); 117 | $this->assertEquals('Marick', $email->from['name']); 118 | } 119 | } 120 | 121 | class TestMailable extends Mailable 122 | { 123 | public function content(): Content 124 | { 125 | $content = new Content( 126 | 'tests::dummy' 127 | ); 128 | 129 | $content->with('name', 'John Doe'); 130 | 131 | return $content; 132 | } 133 | 134 | public function envelope(): Envelope 135 | { 136 | return new Envelope( 137 | null, 138 | [ 139 | new Address('john@doe.com', 'John Doe'), 140 | ], 141 | ['john+cc@doe.com', 'john+cc2@doe.com'], 142 | ['john+bcc@doe.com', 'john+bcc2@doe.com'], 143 | ['replyto@example.com', new Address('replyto2@example.com')], 144 | 'Your order has shipped!' 145 | ); 146 | } 147 | 148 | public function attachments(): array 149 | { 150 | return [ 151 | Attachment::fromPath(__DIR__.'/files/pdf-sample.pdf')->withMime('application/pdf'), 152 | Attachment::fromStorageDisk(__DIR__.'/files/pdf-sample.pdf', 'my-local-disk')->withMime('application/pdf'), 153 | ]; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /tests/PruneTest.php: -------------------------------------------------------------------------------- 1 | sendEmail(); 15 | 16 | Carbon::setTestNow($email->created_at.' + 6 months'); 17 | $this->artisan('model:prune', ['--model' => [Email::class]]); 18 | $this->assertInstanceOf(Email::class, $email->fresh()); 19 | 20 | Carbon::setTestNow($email->created_at.' + 6 months + 1 day'); 21 | 22 | // Ensure the email object has to be passed manually, otherwise we are acidentally 23 | // deleting everyone's e-mails... 24 | $this->artisan('model:prune'); 25 | $this->assertInstanceOf(Email::class, $email->fresh()); 26 | 27 | // Now test with it passed... then it should definitely be deleted. 28 | $this->artisan('model:prune', ['--model' => [Email::class]]); 29 | $this->assertNull($email->fresh()); 30 | } 31 | 32 | #[Test] 33 | public function can_change_when_emails_are_pruned() 34 | { 35 | Email::pruneWhen(function (Email $email) { 36 | return $email->where('created_at', '<', now()->subMonths(3)); 37 | }); 38 | 39 | $email = $this->sendEmail(); 40 | 41 | Carbon::setTestNow($email->created_at.' + 3 months'); 42 | $this->artisan('model:prune', ['--model' => [Email::class]]); 43 | $this->assertInstanceOf(Email::class, $email->fresh()); 44 | 45 | Carbon::setTestNow($email->created_at.' + 3 months + 1 day'); 46 | $this->artisan('model:prune', ['--model' => [Email::class]]); 47 | $this->assertNull($email->fresh()); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/QueuedEmailsTest.php: -------------------------------------------------------------------------------- 1 | queueEmail(); 19 | 20 | $this->assertEquals(0, $email->sending); 21 | } 22 | 23 | #[Test] 24 | public function queueing_an_email_will_set_the_queued_at_column() 25 | { 26 | Queue::fake(); 27 | 28 | $email = $this->queueEmail(); 29 | 30 | $this->assertNotNull($email->queued_at); 31 | } 32 | 33 | #[Test] 34 | public function queueing_an_email_will_dispatch_a_job() 35 | { 36 | Queue::fake(); 37 | 38 | $email = $this->queueEmail(); 39 | 40 | Queue::assertPushed(SendEmailJob::class, function (SendEmailJob $job) use ($email) { 41 | return $job->email->id === $email->id; 42 | }); 43 | } 44 | 45 | #[Test] 46 | public function emails_can_be_queued_on_a_specific_connection() 47 | { 48 | Queue::fake(); 49 | 50 | $this->queueEmail('some-connection'); 51 | 52 | Queue::assertPushed(SendEmailJob::class, function (SendEmailJob $job) { 53 | return $job->connection === 'some-connection'; 54 | }); 55 | } 56 | 57 | #[Test] 58 | public function emails_can_be_queued_on_a_specific_queue() 59 | { 60 | Queue::fake(); 61 | 62 | $this->queueEmail('default', 'some-queue'); 63 | 64 | Queue::assertPushed(SendEmailJob::class, function (SendEmailJob $job) { 65 | return $job->queue === 'some-queue'; 66 | }); 67 | } 68 | 69 | #[Test] 70 | public function emails_can_be_queued_with_a_delay() 71 | { 72 | Queue::fake(); 73 | 74 | $delay = now()->addMinutes(6); 75 | 76 | $this->queueEmail(null, null, $delay); 77 | 78 | Queue::assertPushed(SendEmailJob::class, function (SendEmailJob $job) use ($delay) { 79 | return $job->delay->getTimestamp() === $delay->timestamp; 80 | }); 81 | } 82 | 83 | #[Test] 84 | public function the_send_email_job_will_call_send_on_the_email_instance() 85 | { 86 | Queue::fake(); 87 | 88 | $email = $this->queueEmail('default', 'some-queue'); 89 | 90 | $job = new SendEmailJob($email); 91 | 92 | Mail::shouldReceive('send')->once(); 93 | 94 | $job->handle(); 95 | } 96 | 97 | #[Test] 98 | public function the_mail_will_be_marked_as_sent_when_job_is_finished() 99 | { 100 | Queue::fake(); 101 | 102 | $email = $this->queueEmail('default', 'some-queue'); 103 | 104 | $job = new SendEmailJob($email); 105 | $job->handle(); 106 | 107 | $this->assertTrue($email->isSent()); 108 | } 109 | 110 | #[Test] 111 | public function developers_can_choose_their_own_job() 112 | { 113 | Queue::fake(); 114 | 115 | $email = $this->queueEmail(jobClass: CustomSendEmailJob::class); 116 | 117 | Queue::assertPushed(fn (CustomSendEmailJob $job) => $job->email->id === $email->id); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /tests/SendEmailsCommandTest.php: -------------------------------------------------------------------------------- 1 | sendEmail(); 17 | 18 | $this->artisan('email:send'); 19 | 20 | $this->assertNotNull($email->fresh()->sent_at); 21 | } 22 | 23 | #[Test] 24 | public function the_number_of_attempts_should_be_incremented() 25 | { 26 | $email = $this->sendEmail(); 27 | 28 | $this->assertEquals(0, $email->fresh()->attempts); 29 | 30 | $this->artisan('email:send'); 31 | 32 | $this->assertEquals(1, $email->fresh()->attempts); 33 | } 34 | 35 | #[Test] 36 | public function an_email_should_not_be_sent_once_it_is_marked_as_sent() 37 | { 38 | $email = $this->sendEmail(); 39 | 40 | $this->artisan('email:send'); 41 | 42 | $this->assertNotNull($firstSend = $email->fresh()->sent_at); 43 | 44 | $this->artisan('email:send'); 45 | 46 | $this->assertEquals(1, $email->fresh()->attempts); 47 | $this->assertEquals($firstSend, $email->fresh()->sent_at); 48 | } 49 | 50 | #[Test] 51 | public function an_email_should_not_be_sent_if_it_is_queued() 52 | { 53 | Queue::fake(); 54 | 55 | $email = $this->queueEmail(); 56 | 57 | $this->artisan('email:send'); 58 | 59 | $this->assertNull($email->fresh()->sent_at); 60 | } 61 | 62 | #[Test] 63 | public function if_an_email_fails_to_be_sent_it_should_be_logged_in_the_database() 64 | { 65 | $email = $this->sendEmail(); 66 | 67 | $email->update(['recipient' => ['asdf' => null]]); 68 | 69 | $this->artisan('email:send'); 70 | 71 | $this->assertTrue($email->fresh()->hasFailed()); 72 | $this->assertStringContainsString('RfcComplianceException', $email->fresh()->error); 73 | } 74 | 75 | #[Test] 76 | public function the_number_of_emails_sent_per_minute_should_be_limited() 77 | { 78 | for ($i = 1; $i <= 30; $i++) { 79 | $this->sendEmail(); 80 | } 81 | 82 | $this->app['config']['database-emails.limit'] = 25; 83 | 84 | $this->artisan('email:send'); 85 | 86 | $this->assertEquals(5, DB::table('emails')->whereNull('sent_at')->count()); 87 | } 88 | 89 | #[Test] 90 | public function an_email_should_never_be_sent_before_its_scheduled_date() 91 | { 92 | $email = $this->scheduleEmail(Carbon::now()->addHour(1)); 93 | $this->artisan('email:send'); 94 | $email = $email->fresh(); 95 | $this->assertEquals(0, $email->attempts); 96 | $this->assertNull($email->sent_at); 97 | 98 | $email->update(['scheduled_at' => Carbon::now()->toDateTimeString()]); 99 | $this->artisan('email:send'); 100 | $email = $email->fresh(); 101 | $this->assertEquals(1, $email->attempts); 102 | $this->assertNotNull($email->sent_at); 103 | } 104 | 105 | #[Test] 106 | public function emails_will_be_sent_until_max_try_count_has_been_reached() 107 | { 108 | $this->app['config']['mail.driver'] = 'does-not-exist'; 109 | 110 | $this->sendEmail(); 111 | $this->assertCount(1, (new Store)->getQueue()); 112 | $this->artisan('email:send'); 113 | $this->assertCount(1, (new Store)->getQueue()); 114 | $this->artisan('email:send'); 115 | $this->assertCount(1, (new Store)->getQueue()); 116 | $this->artisan('email:send'); 117 | $this->assertCount(0, (new Store)->getQueue()); 118 | } 119 | 120 | #[Test] 121 | public function the_failed_status_and_error_is_cleared_if_a_previously_failed_email_is_sent_succesfully() 122 | { 123 | $email = $this->sendEmail(); 124 | 125 | $email->update([ 126 | 'failed' => true, 127 | 'error' => 'Simulating some random error', 128 | 'attempts' => 1, 129 | ]); 130 | 131 | $this->assertTrue($email->fresh()->failed); 132 | $this->assertEquals('Simulating some random error', $email->fresh()->error); 133 | 134 | $this->artisan('email:send'); 135 | 136 | $this->assertFalse($email->fresh()->failed); 137 | $this->assertEmpty($email->fresh()->error); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /tests/SenderTest.php: -------------------------------------------------------------------------------- 1 | */ 18 | public $sent = []; 19 | 20 | protected function setUp(): void 21 | { 22 | parent::setUp(); 23 | 24 | Event::listen(MessageSent::class, function (MessageSent $event) { 25 | $this->sent[] = SentMessage::createFromSymfonyMailer( 26 | $event->message->getSymfonySentMessage()->getOriginalMessage() 27 | ); 28 | }); 29 | } 30 | 31 | #[Test] 32 | public function it_sends_an_email() 33 | { 34 | $this->sendEmail(); 35 | 36 | Mail::shouldReceive('send')->once(); 37 | 38 | $this->artisan('email:send'); 39 | } 40 | 41 | #[Test] 42 | public function the_email_has_a_correct_from_email_and_from_name() 43 | { 44 | $this->app['config']->set('mail.from.address', 'testfromaddress@gmail.com'); 45 | $this->app['config']->set('mail.from.name', 'From CI test'); 46 | 47 | $this->sendEmail(); 48 | 49 | $this->artisan('email:send'); 50 | 51 | $from = reset($this->sent)->from; 52 | 53 | $this->assertEquals('testfromaddress@gmail.com', key($from)); 54 | $this->assertEquals('From CI test', $from[key($from)]); 55 | 56 | // custom from... 57 | $this->sent = []; 58 | 59 | $this->composeEmail(['from' => new Address('marick@dolphiq.nl', 'Marick')])->send(); 60 | $this->artisan('email:send'); 61 | $from = reset($this->sent)->from; 62 | $this->assertEquals('marick@dolphiq.nl', key($from)); 63 | $this->assertEquals('Marick', $from[key($from)]); 64 | 65 | // only address 66 | $this->sent = []; 67 | $this->composeEmail(['from' => 'marick@dolphiq.nl'])->send(); 68 | $this->artisan('email:send'); 69 | $from = reset($this->sent)->from; 70 | $this->assertEquals('marick@dolphiq.nl', key($from)); 71 | $this->assertEquals('From CI test', $from[key($from)]); 72 | } 73 | 74 | #[Test] 75 | public function it_sends_emails_to_the_correct_recipients() 76 | { 77 | $this->sendEmail(['recipient' => 'john@doe.com']); 78 | $this->artisan('email:send'); 79 | $to = reset($this->sent)->to; 80 | $this->assertCount(1, $to); 81 | $this->assertArrayHasKey('john@doe.com', $to); 82 | 83 | $this->sent = []; 84 | $this->sendEmail(['recipient' => ['john@doe.com', 'john+2@doe.com']]); 85 | $this->artisan('email:send'); 86 | $to = reset($this->sent)->to; 87 | $this->assertCount(2, $to); 88 | $this->assertArrayHasKey('john@doe.com', $to); 89 | $this->assertArrayHasKey('john+2@doe.com', $to); 90 | } 91 | 92 | #[Test] 93 | public function it_adds_the_cc_addresses() 94 | { 95 | $this->sendEmail(['cc' => 'cc@test.com']); 96 | $this->artisan('email:send'); 97 | $cc = reset($this->sent)->cc; 98 | $this->assertCount(1, $cc); 99 | $this->assertArrayHasKey('cc@test.com', $cc); 100 | 101 | $this->sent = []; 102 | $this->sendEmail(['cc' => ['cc@test.com', 'cc+2@test.com']]); 103 | $this->artisan('email:send'); 104 | $cc = reset($this->sent)->cc; 105 | $this->assertCount(2, $cc); 106 | $this->assertArrayHasKey('cc@test.com', $cc); 107 | $this->assertArrayHasKey('cc+2@test.com', $cc); 108 | } 109 | 110 | #[Test] 111 | public function it_adds_the_bcc_addresses() 112 | { 113 | $this->sendEmail(['bcc' => 'bcc@test.com']); 114 | $this->artisan('email:send'); 115 | $bcc = reset($this->sent)->bcc; 116 | $this->assertCount(1, $bcc); 117 | $this->assertArrayHasKey('bcc@test.com', $bcc); 118 | 119 | $this->sent = []; 120 | $this->sendEmail(['bcc' => ['bcc@test.com', 'bcc+2@test.com']]); 121 | $this->artisan('email:send'); 122 | $bcc = reset($this->sent)->bcc; 123 | $this->assertCount(2, $bcc); 124 | $this->assertArrayHasKey('bcc@test.com', $bcc); 125 | $this->assertArrayHasKey('bcc+2@test.com', $bcc); 126 | } 127 | 128 | #[Test] 129 | public function the_email_has_the_correct_subject() 130 | { 131 | $this->sendEmail(['subject' => 'Hello World']); 132 | 133 | $this->artisan('email:send'); 134 | 135 | $subject = reset($this->sent)->subject; 136 | 137 | $this->assertEquals('Hello World', $subject); 138 | } 139 | 140 | #[Test] 141 | public function the_email_has_the_correct_body() 142 | { 143 | $this->sendEmail(['variables' => ['name' => 'John Doe']]); 144 | $this->artisan('email:send'); 145 | $body = reset($this->sent)->body; 146 | $this->assertEquals((string) view('tests::dummy', ['name' => 'John Doe']), $body); 147 | 148 | $this->sent = []; 149 | $this->sendEmail(['variables' => []]); 150 | $this->artisan('email:send'); 151 | $body = reset($this->sent)->body; 152 | $this->assertEquals(view('tests::dummy'), $body); 153 | } 154 | 155 | #[Test] 156 | public function attachments_are_added_to_the_email() 157 | { 158 | $this->composeEmail() 159 | ->attachments([ 160 | Attachment::fromPath(__DIR__.'/files/pdf-sample.pdf'), 161 | Attachment::fromPath(__DIR__.'/files/my-file.txt')->as('Test123 file'), 162 | Attachment::fromStorageDisk('my-custom-disk', 'test.txt'), 163 | ]) 164 | ->send(); 165 | $this->artisan('email:send'); 166 | 167 | $attachments = reset($this->sent)->attachments; 168 | 169 | $this->assertCount(3, $attachments); 170 | $this->assertEquals('Test123'."\n", $attachments[1]['body']); 171 | $this->assertEquals('text/plain disposition: attachment filename: Test123 file', $attachments[1]['disposition']); 172 | $this->assertEquals("my file from public disk\n", $attachments[2]['body']); 173 | } 174 | 175 | #[Test] 176 | public function raw_attachments_are_not_added_to_the_email() 177 | { 178 | $this->expectException(RuntimeException::class); 179 | $this->expectExceptionMessage('Raw attachments are not supported in the database email driver.'); 180 | 181 | $this->composeEmail() 182 | ->attachments([ 183 | Attachment::fromData(fn () => 'test', 'test.txt'), 184 | ]) 185 | ->send(); 186 | } 187 | 188 | #[Test] 189 | public function emails_can_be_sent_immediately() 190 | { 191 | $this->app['config']->set('database-emails.immediately', false); 192 | $this->sendEmail(); 193 | $this->assertCount(0, $this->sent); 194 | Email::truncate(); 195 | 196 | $this->app['config']->set('database-emails.immediately', true); 197 | $this->sendEmail(); 198 | $this->assertCount(1, $this->sent); 199 | 200 | $this->artisan('email:send'); 201 | $this->assertCount(1, $this->sent); 202 | } 203 | 204 | #[Test] 205 | public function it_adds_the_reply_to_addresses() 206 | { 207 | $this->sendEmail(['reply_to' => 'replyto@test.com']); 208 | $this->artisan('email:send'); 209 | $replyTo = reset($this->sent)->replyTo; 210 | $this->assertCount(1, $replyTo); 211 | $this->assertArrayHasKey('replyto@test.com', $replyTo); 212 | 213 | $this->sent = []; 214 | $this->sendEmail(['reply_to' => ['replyto1@test.com', 'replyto2@test.com']]); 215 | $this->artisan('email:send'); 216 | $replyTo = reset($this->sent)->replyTo; 217 | $this->assertCount(2, $replyTo); 218 | $this->assertArrayHasKey('replyto1@test.com', $replyTo); 219 | $this->assertArrayHasKey('replyto2@test.com', $replyTo); 220 | 221 | $this->sent = []; 222 | $this->sendEmail([ 223 | 'reply_to' => new Address('replyto@test.com', 'NoReplyTest'), 224 | ]); 225 | $this->artisan('email:send'); 226 | $replyTo = reset($this->sent)->replyTo; 227 | $this->assertCount(1, $replyTo); 228 | $this->assertSame(['replyto@test.com' => 'NoReplyTest'], $replyTo); 229 | 230 | $this->sent = []; 231 | $this->sendEmail([ 232 | 'reply_to' => [ 233 | new Address('replyto@test.com', 'NoReplyTest'), 234 | new Address('replyto2@test.com', 'NoReplyTest2'), 235 | ], 236 | ]); 237 | $this->artisan('email:send'); 238 | $replyTo = reset($this->sent)->replyTo; 239 | $this->assertCount(2, $replyTo); 240 | $this->assertSame( 241 | [ 242 | 'replyto@test.com' => 'NoReplyTest', 243 | 'replyto2@test.com' => 'NoReplyTest2', 244 | ], 245 | $replyTo 246 | ); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | invalid = [ 25 | true, 26 | 1, 27 | 1.0, 28 | 'test', 29 | new \stdClass, 30 | (object) [], 31 | function () {}, 32 | ]; 33 | 34 | view()->addNamespace('tests', __DIR__.'/views'); 35 | 36 | $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); 37 | 38 | Email::truncate(); 39 | } 40 | 41 | protected function getPackageProviders($app) 42 | { 43 | return [ 44 | LaravelDatabaseEmailsServiceProvider::class, 45 | ]; 46 | } 47 | 48 | /** 49 | * Define environment setup. 50 | * 51 | * @param \Illuminate\Foundation\Application $app 52 | * @return void 53 | */ 54 | protected function getEnvironmentSetUp($app) 55 | { 56 | $app['config']->set('database-emails.attempts', 3); 57 | $app['config']->set('database-emails.testing.enabled', false); 58 | $app['config']->set('database-emails.testing.email', 'test@email.com'); 59 | 60 | $app['config']->set('filesystems.disks.my-custom-disk', [ 61 | 'driver' => 'local', 62 | 'root' => __DIR__.'/../workbench/storage/app/public', 63 | ]); 64 | 65 | $app['config']->set('database.default', 'testbench'); 66 | $driver = env('DB_DRIVER', 'sqlite'); 67 | $app['config']->set('database.connections.testbench', [ 68 | 'driver' => $driver, 69 | ...match ($driver) { 70 | 'sqlite' => [ 71 | 'database' => database_path('database.sqlite'), 72 | ], 73 | 'mysql' => [ 74 | 'host' => '127.0.0.1', 75 | 'port' => 3307, 76 | 'database' => 'test', 77 | 'username' => 'test', 78 | 'password' => 'test', 79 | ], 80 | 'pgsql' => [ 81 | 'host' => '127.0.0.1', 82 | 'port' => 5432, 83 | 'database' => 'test', 84 | 'username' => 'test', 85 | 'password' => 'test', 86 | ], 87 | }, 88 | ]); 89 | 90 | $app['config']->set('mail.driver', 'log'); 91 | 92 | $app['config']->set('mail.from.name', 'Laravel'); 93 | } 94 | 95 | public function createEmail($overwrite = []) 96 | { 97 | $params = array_merge([ 98 | 'label' => 'welcome', 99 | 'recipient' => 'john@doe.com', 100 | 'cc' => null, 101 | 'bcc' => null, 102 | 'reply_to' => null, 103 | 'subject' => 'test', 104 | 'view' => 'tests::dummy', 105 | 'variables' => ['name' => 'John Doe'], 106 | 'from' => null, 107 | ], $overwrite); 108 | 109 | return Email::compose() 110 | ->label($params['label']) 111 | ->envelope(fn (Envelope $envelope) => $envelope 112 | ->to($params['recipient']) 113 | ->when($params['cc'], fn ($envelope) => $envelope->cc($params['cc'])) 114 | ->when($params['bcc'], fn ($envelope) => $envelope->bcc($params['bcc'])) 115 | ->when($params['reply_to'], fn ($envelope) => $envelope->replyTo($params['reply_to'])) 116 | ->when($params['from'], fn (Envelope $envelope) => $envelope->from($params['from'])) 117 | ->subject($params['subject'])) 118 | ->content(fn (Content $content) => $content 119 | ->view($params['view']) 120 | ->with($params['variables']) 121 | ); 122 | } 123 | 124 | public function composeEmail($overwrite = []) 125 | { 126 | return $this->createEmail($overwrite); 127 | } 128 | 129 | public function sendEmail($overwrite = []) 130 | { 131 | return $this->createEmail($overwrite)->send(); 132 | } 133 | 134 | public function scheduleEmail($scheduledFor, $overwrite = []) 135 | { 136 | return $this->createEmail($overwrite)->later($scheduledFor); 137 | } 138 | 139 | public function queueEmail($connection = null, $queue = null, $delay = null, $overwrite = [], ?string $jobClass = null) 140 | { 141 | return $this->createEmail($overwrite)->queue($connection, $queue, $delay, $jobClass); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /tests/files/my-file.txt: -------------------------------------------------------------------------------- 1 | Test123 2 | -------------------------------------------------------------------------------- /tests/files/pdf-sample.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackkit/laravel-database-emails/4c77afc6f653395d6319a0d88e8dd28342e3a505/tests/files/pdf-sample.pdf -------------------------------------------------------------------------------- /tests/views/dummy.blade.php: -------------------------------------------------------------------------------- 1 | @if (isset($name)) 2 | Name: {{ $name }} 3 | @else 4 | This view has no variables. 5 | @endif -------------------------------------------------------------------------------- /tests/views/welcome.blade.php: -------------------------------------------------------------------------------- 1 | Welcome -------------------------------------------------------------------------------- /workbench/app/Jobs/CustomSendEmailJob.php: -------------------------------------------------------------------------------- 1 | loadTranslationsFrom( 15 | __DIR__.'/../../lang', 16 | 'package' 17 | ); 18 | } 19 | 20 | /** 21 | * Bootstrap services. 22 | */ 23 | public function boot(): void 24 | { 25 | // 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /workbench/bootstrap/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackkit/laravel-database-emails/4c77afc6f653395d6319a0d88e8dd28342e3a505/workbench/bootstrap/.gitkeep -------------------------------------------------------------------------------- /workbench/bootstrap/app.php: -------------------------------------------------------------------------------- 1 | withRouting( 11 | web: __DIR__.'/../routes/web.php', 12 | commands: __DIR__.'/../routes/console.php', 13 | ) 14 | ->withMiddleware(function (Middleware $middleware) { 15 | // 16 | }) 17 | ->withExceptions(function (Exceptions $exceptions) { 18 | // 19 | })->create(); 20 | -------------------------------------------------------------------------------- /workbench/bootstrap/providers.php: -------------------------------------------------------------------------------- 1 | 'Hello!', 5 | ]; 6 | -------------------------------------------------------------------------------- /workbench/lang/fil-PH/messages.php: -------------------------------------------------------------------------------- 1 | 'Kumusta!', 5 | ]; 6 | -------------------------------------------------------------------------------- /workbench/resources/views/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackkit/laravel-database-emails/4c77afc6f653395d6319a0d88e8dd28342e3a505/workbench/resources/views/.gitkeep -------------------------------------------------------------------------------- /workbench/resources/views/locale-email.blade.php: -------------------------------------------------------------------------------- 1 | {{ trans('workbench::messages.greeting') }} 2 | 3 | -------------------------------------------------------------------------------- /workbench/routes/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackkit/laravel-database-emails/4c77afc6f653395d6319a0d88e8dd28342e3a505/workbench/routes/.gitkeep -------------------------------------------------------------------------------- /workbench/routes/console.php: -------------------------------------------------------------------------------- 1 | comment(Inspiring::quote()); 8 | })->purpose('Display an inspiring quote')->hourly(); 9 | -------------------------------------------------------------------------------- /workbench/routes/web.php: -------------------------------------------------------------------------------- 1 |