├── .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 | [](https://github.com/stackkit/laravel-database-emails/actions/workflows/run-tests.yml)
2 | [](https://packagist.org/packages/stackkit/laravel-database-emails)
3 | [](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 |