diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml
index c0fbc53..e60152a 100644
--- a/.github/workflows/codecov.yml
+++ b/.github/workflows/codecov.yml
@@ -15,10 +15,10 @@ jobs:
- ubuntu-latest
php:
- - "8.2"
- "8.3"
- "8.4"
- "8.5"
+ - "8.6"
steps:
- name: Checkout
diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml
index 9976515..1bf077c 100644
--- a/.github/workflows/static-analysis.yml
+++ b/.github/workflows/static-analysis.yml
@@ -15,10 +15,10 @@ jobs:
- ubuntu-latest
php:
- - "8.2"
- "8.3"
- "8.4"
- "8.5"
+ - "8.6"
steps:
- name: Checkout
diff --git a/.laminas-ci.json b/.laminas-ci.json
index 29216fa..e827682 100644
--- a/.laminas-ci.json
+++ b/.laminas-ci.json
@@ -1,3 +1,6 @@
{
+ "ignore_php_platform_requirements": {
+ "8.6": true
+ },
"backwardCompatibilityCheck": true
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 382f245..c0fd414 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,418 +1,1204 @@
+# Changelog
+
+## 6.0.0 - Unreleased
+
+### Changed
+
+- **BC:** mail services are configured under `dot_mail.services`; `dot_mail.log` stays separate. A configuration in the old layout (`dot_mail.default`) now fails with an `InvalidArgumentException`. Move the service blocks as shown in `config/mail.global.php.dist`.
+- **BC:** `send()` restores the configured defaults after every attempt, successful or not: recipients, subject, body and attachments must be set again for every message. `getMessage()` returns a new object after each send.
+- **BC:** a missing or unreadable attachment now throws a `MailException` instead of being skipped.
+- **BC:** the attachment directory (`attachments.dir`) is scanned at send time instead of once at start-up, and files starting with a dot are skipped. Attachments are read with `DataPart::fromPath()`, so no file handles are left open.
+- **BC:** `send()` handles any `Throwable`, and the `MailException` it throws carries the original exception and code. `ResultInterface`, `MailResult` and `createMailResultFromException()` use `Throwable` instead of `Exception`.
+- **BC:** an exception thrown by a post-send listener propagates unchanged and is no longer reported as a send error, because the message was already delivered.
+- **BC:** the `esmtp` transport is built directly from `smtp_options` instead of through a DSN string. Credentials with special characters work, empty credentials are not sent, and `smtp_options.name` and `connection_time_limit` now take effect. `SmtpOptions::getName()` returns `?string` and defaults to `null`.
+- **BC:** a transport object or container service provided in `transport` is used as given and no longer replaced.
+- **BC:** `sendmail` is the default transport when the `transport` key is omitted, matching `config/mail.global.php.dist` and the documentation.
+- **BC:** `MailEvent::getResult()` returns `?ResultInterface`; the result is `null` in the pre-send event.
+- **BC:** a message that never received a body fails validation instead of being sent empty; an empty `body.content` in the configuration is no longer applied.
+- **BC:** the charset passed to `setBody()` and `body.charset` are used; `send()` only sets `utf-8` when no encoding is set.
+- **BC:** the minimum supported PHP version is 8.3 (`~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0`).
+- A failing log write no longer turns a delivered email into an error; `LogService::sent()` returns `false` when the log directory cannot be created, and writes use `LOCK_EX`.
+- Option getters fall back to defaults, so a minimal configuration no longer fails with uninitialized properties.
+
+### Added
+
+- `MailServiceInterface::reset()`, which restores the configured message and attachments. The `MailService` constructor accepts the default attachments as an optional fifth argument.
+- `MailServiceInterface` is aliased to `dot-mail.service.default`, so it can be injected by interface.
+- PHP 8.6 in the version constraint and in the CI matrix.
+- `laminas/laminas-stdlib` as a direct dependency.
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Support for PHP 8.2.
+- Unused attachment handling code from `Dot\Mail\Email`.
+
+### Fixed
+
+- `Email::addTo()` and the other address methods accept arrays of `Address` objects.
+- Transport aliases are matched case-insensitively (`ESMTP`, `SendMail`).
+- Missing `username`, `password` or `tls` keys in `smtp_options.connection_config` no longer raise warnings, and a username with special characters is encoded correctly.
+- `canCreate()` no longer reads an undefined array key for names with too few parts.
+- Detaching a listener that was never attached no longer removes a wrong entry.
+- Error messages for an invalid transport, an invalid event listener and an invalid port are accurate.
+
+## 5.4.3 - 2026-05-11
+
+### Changed
+
+- Nothing
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- Attachments of a failed send no longer leak into the next email; the message body is reset after every send attempt ([#115](https://github.com/dotkernel/dot-mail/pull/115))
+
+## 5.4.2 - 2026-03-03
+
+### Changed
+
+- Nothing
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- `MailService`: fixed the email structure when sending multiple attachments ([#113](https://github.com/dotkernel/dot-mail/pull/113))
+
+## 5.4.1 - 2025-11-12
+
+### Changed
+
+- Updated OSS lifecycle metadata ([#111](https://github.com/dotkernel/dot-mail/pull/111))
+- Updated readme and documentation ([#112](https://github.com/dotkernel/dot-mail/pull/112))
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- Nothing
+
+## 5.4.0 - 2025-11-03
+
+### Changed
+
+- Nothing
+
+### Added
+
+- Support for PHP 8.4 and 8.5 ([#104](https://github.com/dotkernel/dot-mail/pull/104))
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Support for PHP 8.1 ([#104](https://github.com/dotkernel/dot-mail/pull/104))
+
+### Fixed
+
+- Linking between documentation versions ([#105](https://github.com/dotkernel/dot-mail/pull/105))
+
+## 5.1.6 - 2025-11-03
+
+### Changed
+
+- Nothing
+
+### Added
+
+- Support for PHP 8.4 and 8.5 ([#103](https://github.com/dotkernel/dot-mail/pull/103))
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Support for PHP 8.1 ([#103](https://github.com/dotkernel/dot-mail/pull/103))
+
+### Fixed
+
+- Nothing
+
+## 5.0.4 - 2025-11-03
+
+### Changed
+
+- Documentation: version `5.0` is the default ([#75](https://github.com/dotkernel/dot-mail/pull/75))
+- Updated the v5 documentation ([#78](https://github.com/dotkernel/dot-mail/pull/78))
+- Bumped Psalm to 6.x ([#84](https://github.com/dotkernel/dot-mail/pull/84))
+
+### Added
+
+- Support for `laminas/laminas-servicemanager` 4.x ([#82](https://github.com/dotkernel/dot-mail/pull/82))
+- Upgrade guide from v4 to v5 ([#81](https://github.com/dotkernel/dot-mail/pull/81))
+- Support for PHP 8.4 and 8.5 ([#102](https://github.com/dotkernel/dot-mail/pull/102))
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Unused code inherited from version 4.0 ([#73](https://github.com/dotkernel/dot-mail/pull/73))
+- Support for PHP 8.1 ([#102](https://github.com/dotkernel/dot-mail/pull/102))
+
+### Fixed
+
+- Version links in the documentation footer ([#80](https://github.com/dotkernel/dot-mail/pull/80))
+- Attachments not working ([#87](https://github.com/dotkernel/dot-mail/pull/87))
+
+## 5.3.0 - 2025-03-04
+
+### Changed
+
+- Replaced Psalm with PHPStan ([#90](https://github.com/dotkernel/dot-mail/pull/90))
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Unused code ([#97](https://github.com/dotkernel/dot-mail/pull/97))
+
+### Fixed
+
+- Error caused by special characters ([#94](https://github.com/dotkernel/dot-mail/pull/94))
+
+## 5.1.5 - 2025-02-28
+
+### Changed
+
+- Nothing
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- Error caused by special characters in the password ([#95](https://github.com/dotkernel/dot-mail/pull/95))
+
+## 5.1.4 - 2025-02-17
+
+### Changed
+
+- Nothing
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- Attachments not working ([#88](https://github.com/dotkernel/dot-mail/pull/88))
+
+## 5.2.2 - 2025-02-14
+
+### Changed
+
+- Nothing
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- Attachments not working ([#87](https://github.com/dotkernel/dot-mail/pull/87))
+
+## 5.2.1 - 2025-01-28
+
+### Changed
+
+- Bumped Psalm to 6.x ([#84](https://github.com/dotkernel/dot-mail/pull/84))
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- Nothing
+
+## 5.2.0 - 2025-01-13
+
+### Changed
+
+- Nothing
+
+### Added
+
+- Support for `laminas/laminas-servicemanager` 4.x ([#82](https://github.com/dotkernel/dot-mail/pull/82))
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- Nothing
+
+## 5.1.3 - 2025-01-06
+
+### Changed
+
+- Nothing
+
+### Added
+
+- Upgrade guide from v4 to v5 ([#81](https://github.com/dotkernel/dot-mail/pull/81))
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- Nothing
+
+## 5.1.2 - 2024-12-19
+
+### Changed
+
+- Nothing
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- Version links in the documentation footer ([#80](https://github.com/dotkernel/dot-mail/pull/80))
+
+## 5.1.1 - 2024-12-19
+
+### Changed
+
+- Documentation: version `5.0` is the default ([#75](https://github.com/dotkernel/dot-mail/pull/75))
+- Updated the v5 documentation ([#78](https://github.com/dotkernel/dot-mail/pull/78))
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- Nothing
+
+## 5.1.0 - 2024-12-06
+
+### Changed
+
+- Nothing
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Unused code inherited from version 4.0 ([#73](https://github.com/dotkernel/dot-mail/pull/73))
+
+### Fixed
+
+- Nothing
+
+## 5.0.3 - 2024-12-03
+
+### Changed
+
+- Added `declare(strict_types=1)` to the configuration file ([#69](https://github.com/dotkernel/dot-mail/pull/69))
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- Nothing
+
+## 5.0.2 - 2024-11-19
+
+### Changed
+
+- Cleaned the configuration file ([#67](https://github.com/dotkernel/dot-mail/pull/67))
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- The "from" name is displayed correctly ([#67](https://github.com/dotkernel/dot-mail/pull/67))
+
+## 5.0.1 - 2024-11-18
+
+### Changed
+
+- Nothing
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- SMTP transport: missing `port` array key ([#66](https://github.com/dotkernel/dot-mail/pull/66))
+
+## 5.0.0 - 2024-11-15
+
+### Changed
+
+- Replaced `laminas-mail` with `symfony/mailer` ([#65](https://github.com/dotkernel/dot-mail/pull/65))
+- Updated readme ([#61](https://github.com/dotkernel/dot-mail/pull/61))
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- `mime` and `imap` related functionality (see the upgrade guide from v4 to v5)
+
+### Fixed
+
+- Nothing
+
+## 4.3.0 - 2024-10-28
+
+### Changed
+
+- Final release of the wrapper over `laminas-mail`
+- Updated readme ([#61](https://github.com/dotkernel/dot-mail/pull/61))
+- Locked PHP at version 8.3 ([#64](https://github.com/dotkernel/dot-mail/pull/64))
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- Nothing
+
+## 4.2.2 - 2024-06-28
+
+### Changed
+
+- Updated the documentation overview ([#59](https://github.com/dotkernel/dot-mail/pull/59))
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- Nothing
+
+## 4.2.1 - 2024-06-18
+
+### Changed
+
+- Updated the documentation overview ([#58](https://github.com/dotkernel/dot-mail/pull/58))
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- Nothing
+
+## 4.2.0 - 2024-05-03
+
+### Changed
+
+- Updated workflows ([#55](https://github.com/dotkernel/dot-mail/pull/55))
+
+### Added
+
+- v4 documentation ([#55](https://github.com/dotkernel/dot-mail/pull/55))
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- Nothing
+
+## 4.1.1 - 2023-11-29
+
+### Changed
+
+- Bumped PHP to 8.3 ([#53](https://github.com/dotkernel/dot-mail/pull/53))
+
+### Added
+
+- Codecov badge ([#51](https://github.com/dotkernel/dot-mail/pull/51))
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- Nothing
+
+## 4.1.0 - 2023-08-08
+
+### Changed
+
+- Psalm and phpcs fixes ([#47](https://github.com/dotkernel/dot-mail/pull/47))
+
+### Added
+
+- PHPUnit and tests ([#47](https://github.com/dotkernel/dot-mail/pull/47))
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- Nothing
+
+## 4.0.4 - 2022-09-28
+
+### Changed
+
+- Updated error reporting
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- Nothing
+
+## 3.4.6 - 2022-09-28
+
+### Changed
+
+- Updated error reporting
+
+### Added
+
+- Nothing
+
+### Deprecated
+
+- Nothing
+
+### Removed
+
+- Nothing
+
+### Fixed
+
+- Nothing
+
## 4.0.3 - 2022-08-31
### Changed
-* Improved email storage handling
-* Updated readme
+
+- Improved email storage handling
+- Updated readme
### Added
-* Nothing
+
+- Nothing
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* Nothing
+
+- Nothing
## 4.0.2 - 2022-08-23
### Changed
-* Updated mail.global.php.dist
+
+- Updated mail.global.php.dist
### Added
-* Nothing
+
+- Nothing
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* Nothing
+
+- Nothing
## 4.0.1 - 2022-08-12
### Changed
-* Updated readme
-* Updated mail.global.php.dist
+
+- Updated readme
+- Updated mail.global.php.dist
### Added
-* Nothing
+
+- Nothing
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* Nothing
+
+- Nothing
## 4.0.0 - 2022-08-11
### Changed
-* Updated dependecies versions, including PHP to ~8.0.0 || ~8.1.0
-* Restricted laminas-mail version to >=2.17
+
+- Updated dependecies versions, including PHP to ~8.0.0 || ~8.1.0
+- Restricted laminas-mail version to >=2.17
### Added
-* Saving outgoing emails into folders
+
+- Saving outgoing emails into folders
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* Nothing
+
+- Nothing
## 3.4.5 - 2022-08-30
### Changed
-* Updated readme
+
+- Updated readme
### Added
-* Nothing
+
+- Nothing
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* Nothing
+
+- Nothing
## 3.4.4 - 2022-08-26
### Changed
-* Nothing
+
+- Nothing
### Added
-* Nothing
+
+- Nothing
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* Updated check for storage
+
+- Updated check for storage
## 3.4.3 - 2022-08-25
### Changed
-* Improved email storage handling
+
+- Improved email storage handling
### Added
-* Nothing
+
+- Nothing
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* Nothing
+
+- Nothing
## 3.4.2 - 2022-08-23
### Changed
-* Updated mail.global.php.dist
+
+- Updated mail.global.php.dist
### Added
-* Nothing
+
+- Nothing
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* Nothing
+
+- Nothing
## 3.4.1 - 2022-08-12
### Changed
-* Nothing
+
+- Nothing
### Added
-* Nothing
+
+- Nothing
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* laminas/laminas-dependency-plugin
+
+- laminas/laminas-dependency-plugin
### Fixed
-* Nothing
+
+- Nothing
## 3.4.0 - 2022-08-10
### Changed
-* Nothing
+
+- Nothing
### Added
-* Saving outgoing emails into folders
+
+- Saving outgoing emails into folders
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* Nothing
+
+- Nothing
## 3.3.1 - 2022-08-10
### Changed
-* Updated dependecies versions
-* Restricted laminas-mail version to <=2.16
+
+- Updated dependecies versions
+- Restricted laminas-mail version to <=2.16
### Added
-* Nothing
+
+- Nothing
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* Nothing
+
+- Nothing
## 3.3.0 - 2022-06-20
### Changed
-* Updated PHP version to allow 8.0 and 8.1
+
+- Updated PHP version to allow 8.0 and 8.1
### Added
-* PHP 8.0 and 8.1 compatiblity
+
+- PHP 8.0 and 8.1 compatiblity
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* Nothing
+
+- Nothing
## 2.1 - 2021-11-04
### Changed
-* Nothing
+
+- Nothing
### Added
-* Logging sent emails
+
+- Logging sent emails
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* Nothing
+
+- Nothing
## 3.2.1 - 2021-09-24
### Changed
-* Nothing
+
+- Nothing
### Added
-* Nothing
+
+- Nothing
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* log/mail directory privileges
+
+- log/mail directory privileges
## 3.2.0 - 2021-09-23
### Changed
-* Nothing
+
+- Nothing
### Added
-* Logging sent emails
+
+- Logging sent emails
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* Nothing
+
+- Nothing
## 3.1.1 - 2021-06-08
### Changed
-* Nothing
+
+- Nothing
### Added
-* Nothing
+
+- Nothing
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* fix issue #9: UTF-8 characters in message body
+
+- fix issue #9: UTF-8 characters in message body
## 3.1.0 - 2020-11-03
### Changed
-* Composer 2 compatibility.
-* Update composer.json to require version 2 of laminas/laminas-dependency-plugin to be Composer 2 compatible.
+
+- Composer 2 compatibility.
+- Update composer.json to require version 2 of laminas/laminas-dependency-plugin to be Composer 2 compatible.
### Added
-* bump min version of laminas/laminas-mail
+
+- bump min version of laminas/laminas-mail
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* bump min version of laminas/laminas-dependency-plugin
-* bump min version of dotkernel/dot-event
+
+- bump min version of laminas/laminas-dependency-plugin
+- bump min version of dotkernel/dot-event
## 2.0.0 - 2020-01-30
### Changed
-* Laminas and Mezzio migration.
+
+- Laminas and Mezzio migration.
### Added
-* Nothing
+
+- Nothing
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Support
+
+- Support
### Fixed
-* Nothing
+- Nothing
## 1.0.0 - 2018-10-05
### Changed
-* Nothing
+
+- Nothing
### Added
-* Nothing
+
+- Nothing
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* TemplateRendererInterface requirement
-* Templating in mail support
+
+- TemplateRendererInterface requirement
+- Templating in mail support
### Fixed
-* Nothing
+- Nothing
## 0.2.0 - 2018-05-14
### Changed
-* updated `dot-twigrenderer` dependency
+
+- updated `dot-twigrenderer` dependency
### Added
-* Nothing
+
+- Nothing
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* Nothing
+- Nothing
## 0.1.3 - 2017-04-21
### Changed
-* send mail does not rethrow exceptions anymore
+
+- send mail does not rethrow exceptions anymore
### Added
-* Nothing
+
+- Nothing
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* Nothing
+- Nothing
## 0.1.2 - 2017-03-16
### Changed
-* updated composer.json
+
+- updated composer.json
### Added
-* Nothing
+
+- Nothing
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* Nothing
+- Nothing
## 0.1.1 - 2017-03-11
Updated php file headers doc blocks
### Added
-* Nothing
+
+- Nothing
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* Nothing
+- Nothing
## 0.1.0 - 2017-03-09
Initial tagged release
### Added
-* Everything
+
+- Everything
### Deprecated
-* Nothing
+
+- Nothing
### Removed
-* Nothing
+
+- Nothing
### Fixed
-* Nothing
+
+- Nothing
diff --git a/README.md b/README.md
index f3913d7..9c12fd3 100644
--- a/README.md
+++ b/README.md
@@ -10,26 +10,27 @@ Documentation is available at: https://docs.dotkernel.org/dot-mail/.
## Version History
-| Branch | Release | PSR-11 | Mailer | OSS Lifecycle | PHP Version |
-|--------|------------|----------|------------------|-------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------|
-| 5.2 | `>=5.2.0` | 1 \|\| 2 | `symfony/mailer` |  |  |
-| 5.1 | `<5.2.0` | 1 | `symfony/mailer` |  |  |
-| 5.0 | `<5.1.0` | 1 \|\| 2 | `symfony/mailer` |  |  |
-| 4.0 | `<=4.3.0` | 1 | `laminas-mail` |  |  |
+| Branch | Release | PSR-11 | Mailer | OSS Lifecycle | PHP Version |
+|--------|------------------|----------|------------------|-------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------|
+| 6.0 | `>=6.0.0` | 1 \|\| 2 | `symfony/mailer` |  |  |
+| 5.2 | `>=5.2.0 <6.0.0` | 1 \|\| 2 | `symfony/mailer` |  |  |
+| 5.1 | `<5.2.0` | 1 | `symfony/mailer` |  |  |
+| 5.0 | `<5.1.0` | 1 \|\| 2 | `symfony/mailer` |  |  |
+| 4.0 | `<=4.3.0` | 1 | `laminas-mail` |  |  |
## Badges

-
+
[](https://github.com/dotkernel/dot-mail/issues)
[](https://github.com/dotkernel/dot-mail/network)
[](https://github.com/dotkernel/dot-mail/stargazers)
-[](https://github.com/dotkernel/dot-mail/blob/5.2/LICENSE.md)
+[](https://github.com/dotkernel/dot-mail/blob/6.0/LICENSE.md)
-[](https://github.com/dotkernel/dot-mail/actions/workflows/continuous-integration.yml)
-[](https://codecov.io/gh/dotkernel/dot-mail)
-[](https://github.com/dotkernel/dot-mail/actions/workflows/static-analysis.yml)
+[](https://github.com/dotkernel/dot-mail/actions/workflows/continuous-integration.yml)
+[](https://codecov.io/gh/dotkernel/dot-mail)
+[](https://github.com/dotkernel/dot-mail/actions/workflows/static-analysis.yml)
## Installation
diff --git a/SECURITY.md b/SECURITY.md
index d7fa46f..088dc6a 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -4,6 +4,7 @@
| Version | Supported | PHP Version |
|---------|-------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------|
+| 6.0 |  |  |
| 5.2 |  |  |
| 5.1 |  |  |
| 5.0 |  |  |
diff --git a/config/mail.global.php.dist b/config/mail.global.php.dist
index ad0711d..cec53ff 100644
--- a/config/mail.global.php.dist
+++ b/config/mail.global.php.dist
@@ -9,78 +9,81 @@ return [
* what you'll usually do, these configs provide just defaults and options that remain the same for all mails
*/
'dot_mail' => [
- //the key is the mail service name, this is the default one, which does not extend any configuration
- 'default' => [
- //message configuration
- 'message_options' => [
- //from email address of the email
- 'from' => '',
- //from name to be displayed instead of from address
- 'from_name' => '',
- //reply-to email address of the email
- 'reply_to' => '',
- //replyTo name to be displayed instead of the address
- 'reply_to_name' => '',
- //destination email address as string or a list of email addresses
- 'to' => [],
- //copy destination addresses
- 'cc' => [],
- //hidden copy destination addresses
- 'bcc' => [],
- //email subject
- 'subject' => '',
- //body options - content can be plain text, HTML
- 'body' => [
- 'content' => '',
- 'charset' => 'utf-8',
- ],
- //attachments config
- 'attachments' => [
- 'files' => [],
- 'dir' => [
- 'iterate' => false,
- 'path' => 'data/mail/attachments',
- 'recursive' => false,
+ // option to log the SENT emails
+ 'log' => [
+ 'sent' => getcwd() . '/log/mail/sent.log',
+ ],
+ //the mail services, indexed by service name
+ 'services' => [
+ //the key is the mail service name, this is the default one
+ 'default' => [
+ //message configuration
+ 'message_options' => [
+ //from email address of the email
+ 'from' => '',
+ //from name to be displayed instead of from address
+ 'from_name' => '',
+ //reply-to email address of the email
+ 'reply_to' => '',
+ //replyTo name to be displayed instead of the address
+ 'reply_to_name' => '',
+ //destination email address as string or a list of email addresses
+ 'to' => [],
+ //copy destination addresses
+ 'cc' => [],
+ //hidden copy destination addresses
+ 'bcc' => [],
+ //email subject
+ 'subject' => '',
+ //body options - content can be plain text, HTML
+ 'body' => [
+ 'content' => '',
+ 'charset' => 'utf-8',
+ ],
+ //attachments config
+ 'attachments' => [
+ 'files' => [],
+ 'dir' => [
+ 'iterate' => false,
+ 'path' => 'data/mail/attachments',
+ 'recursive' => false,
+ ],
],
],
- ],
- /**
- * the mail transport to use can be any class implementing
- * Symfony\Component\Mailer\Transport\TransportInterface
- *
- * for standard mail transports, you can use these aliases:
- * - sendmail => Symfony\Component\Mailer\Transport\SendmailTransport
- * - esmtp => Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport
- *
- * defaults to sendmail
- **/
- 'transport' => 'sendmail',
- //options that will be used only if esmtp adapter is used
- 'smtp_options' => [
- //hostname or IP address of the mail server
- 'host' => '',
- //port of the mail server - 587 or 465 for secure connections
- 'port' => 587,
- 'connection_config' => [
- //the smtp authentication identity
- 'username' => '',
- //the smtp authentication credential
- 'password' => '',
- /**
- * tls will run by default on this component, use:
- *
- * null - to avoid interfering with automatic encryption
- * false - to disable automatic encryption
- *
- * It's not recommended to disable TLS while connecting to an SMTP server over the Internet
- **/
- 'tls' => null,
+ /**
+ * the mail transport to use can be any class implementing
+ * Symfony\Component\Mailer\Transport\TransportInterface
+ *
+ * for standard mail transports, you can use these aliases:
+ * - sendmail => Symfony\Component\Mailer\Transport\SendmailTransport
+ * - esmtp => Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport
+ *
+ * defaults to sendmail
+ **/
+ 'transport' => 'sendmail',
+ //options that will be used only if esmtp adapter is used
+ 'smtp_options' => [
+ //hostname or IP address of the mail server
+ 'host' => '',
+ //port of the mail server - 587 or 465 for secure connections
+ 'port' => 587,
+ 'connection_config' => [
+ //the smtp authentication identity
+ 'username' => '',
+ //the smtp authentication credential
+ 'password' => '',
+ /**
+ * tls will run by default on this component, use:
+ *
+ * null - to avoid interfering with automatic encryption
+ * false - to disable automatic encryption
+ *
+ * It's not recommended to disable TLS while connecting to an SMTP server over the Internet
+ **/
+ 'tls' => null,
+ ],
],
],
],
- // option to log the SENT emails
- 'log' => [
- 'sent' => getcwd() . '/log/mail/sent.log',
- ],
],
];
diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php
index 6f6ae8e..53ec221 100644
--- a/src/ConfigProvider.php
+++ b/src/ConfigProvider.php
@@ -9,6 +9,7 @@
use Dot\Mail\Factory\MailServiceAbstractFactory;
use Dot\Mail\Service\LogService;
use Dot\Mail\Service\LogServiceInterface;
+use Dot\Mail\Service\MailServiceInterface;
class ConfigProvider
{
@@ -26,7 +27,8 @@ public function getDependencyConfig(): array
LogService::class => LogServiceFactory::class,
],
'aliases' => [
- LogServiceInterface::class => LogService::class,
+ LogServiceInterface::class => LogService::class,
+ MailServiceInterface::class => 'dot-mail.service.default',
],
'abstract_factories' => [
MailServiceAbstractFactory::class,
diff --git a/src/Email.php b/src/Email.php
index 7640ce7..0d605e6 100644
--- a/src/Email.php
+++ b/src/Email.php
@@ -12,20 +12,12 @@
use Symfony\Component\Mime\Message;
use Symfony\Component\Mime\Part\AbstractPart;
use Symfony\Component\Mime\Part\Multipart\AlternativePart;
-use Symfony\Component\Mime\Part\Multipart\MixedPart;
-use Symfony\Component\Mime\Part\Multipart\RelatedPart;
use Symfony\Component\Mime\Part\TextPart;
-use function array_filter;
-use function array_merge;
-use function array_unique;
-use function array_values;
use function is_array;
use function is_string;
-use function preg_match_all;
use function sprintf;
use function sscanf;
-use function str_replace;
class Email extends Message
{
@@ -48,7 +40,6 @@ class Email extends Message
private ?string $html = null;
private ?string $htmlCharset = null;
- private array $attachments = [];
public function subject(string $subject): static
{
@@ -265,8 +256,8 @@ public function ensureValidity(): void
private function ensureBodyValid(): void
{
- if (null === $this->text && null === $this->html && ! $this->attachments) {
- throw new LogicException('A message must have a text or an HTML part or attachments.');
+ if (null === $this->text && null === $this->html) {
+ throw new LogicException('A message must have a text or an HTML part.');
}
}
@@ -274,81 +265,15 @@ private function generateBody(): AbstractPart
{
$this->ensureBodyValid();
- [$htmlPart, $otherParts, $relatedParts] = $this->prepareParts();
-
$part = null === $this->text ? null : new TextPart($this->text, $this->textCharset);
- if (null !== $htmlPart) {
- if (null !== $part) {
- $part = new AlternativePart($part, $htmlPart);
- } else {
- $part = $htmlPart;
- }
- }
-
- if ($relatedParts) {
- $part = new RelatedPart($part, ...$relatedParts);
- }
-
- if ($otherParts) {
- if ($part) {
- $part = new MixedPart($part, ...$otherParts);
- } else {
- $part = new MixedPart(...$otherParts);
- }
+ if (null !== $this->html) {
+ $htmlPart = new TextPart($this->html, $this->htmlCharset, 'html');
+ $part = null !== $part ? new AlternativePart($part, $htmlPart) : $htmlPart;
}
return $part ?? new TextPart((string) $this->text, $this->textCharset);
}
- private function prepareParts(): array
- {
- $names = [];
- $htmlPart = null;
- $html = $this->html;
- if (null !== $html) {
- $htmlPart = new TextPart($html, $this->htmlCharset, 'html');
- $html = $htmlPart->getBody();
-
- $regexes = [
- ']*src\s*=\s*(?:([\'"])cid:(.+?)\\1|cid:([^>\s]+))',
- '<\w+\s+[^>]*background\s*=\s*(?:([\'"])cid:(.+?)\\1|cid:([^>\s]+))',
- ];
- $tmpMatches = [];
- foreach ($regexes as $regex) {
- preg_match_all('/' . $regex . '/i', $html, $tmpMatches);
- $names = array_merge($names, $tmpMatches[2], $tmpMatches[3]);
- }
- $names = array_filter(array_unique($names));
- }
-
- $otherParts = $relatedParts = [];
- foreach ($this->attachments as $part) {
- foreach ($names as $name) {
- if ($name !== $part->getName() && (! $part->hasContentId() || $name !== $part->getContentId())) {
- continue;
- }
- if (isset($relatedParts[$name])) {
- continue 2;
- }
-
- if ($name !== $part->getContentId()) {
- $html = str_replace('cid:' . $name, 'cid:' . $part->getContentId(), $html, $count);
- }
- $relatedParts[$name] = $part;
- $part->setName($part->getContentId())->asInline();
-
- continue 2;
- }
-
- $otherParts[] = $part;
- }
- if (null !== $htmlPart) {
- $htmlPart = new TextPart($html, $this->htmlCharset, 'html');
- }
-
- return [$htmlPart, $otherParts, array_values($relatedParts)];
- }
-
private function setHeaderBody(string $type, string $name, mixed $body): static
{
$this->getHeaders()->setHeaderBody($type, $name, $body);
diff --git a/src/Event/MailEvent.php b/src/Event/MailEvent.php
index 5ad801f..a835825 100644
--- a/src/Event/MailEvent.php
+++ b/src/Event/MailEvent.php
@@ -21,7 +21,7 @@ class MailEvent extends Event
public const EVENT_MAIL_SEND_ERROR = 'event.mail.send.error';
protected MailServiceInterface $mailService;
- protected ResultInterface $result;
+ protected ?ResultInterface $result = null;
public function __construct(MailServiceInterface $mailService, string $name = self::EVENT_MAIL_PRE_SEND)
{
@@ -39,7 +39,7 @@ public function setMailService(MailServiceInterface $mailService): void
$this->mailService = $mailService;
}
- public function getResult(): ResultInterface
+ public function getResult(): ?ResultInterface
{
return $this->result;
}
diff --git a/src/Factory/AbstractMailFactory.php b/src/Factory/AbstractMailFactory.php
index 6ada22a..1794f46 100644
--- a/src/Factory/AbstractMailFactory.php
+++ b/src/Factory/AbstractMailFactory.php
@@ -4,6 +4,7 @@
namespace Dot\Mail\Factory;
+use Dot\Mail\Exception\InvalidArgumentException;
use Laminas\ServiceManager\Factory\AbstractFactoryInterface;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
@@ -13,11 +14,14 @@
use function count;
use function explode;
use function is_array;
+use function sprintf;
abstract class AbstractMailFactory implements AbstractFactoryInterface
{
public const DOT_MAIL_PART = 'dot-mail';
public const SPECIFIC_PART = '';
+ public const SERVICES_KEY = 'services';
+ public const LOG_KEY = 'log';
protected string $configKey = 'dot_mail';
protected array $config = [];
@@ -41,14 +45,34 @@ public function canCreate(ContainerInterface $container, $requestedName): bool
}
/**
+ * Returns the configured mail services, indexed by name (the `dot_mail.services` key).
+ *
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
+ * @throws InvalidArgumentException When the configuration uses the pre-6.0 layout.
*/
public function getConfig(ContainerInterface $container): array
{
$config = $container->get('config');
- if (isset($config[$this->configKey]) && is_array($config[$this->configKey])) {
- $this->config = $config[$this->configKey];
+ if (! isset($config[$this->configKey]) || ! is_array($config[$this->configKey])) {
+ return $this->config;
+ }
+
+ $mailConfig = $config[$this->configKey];
+ if (isset($mailConfig[self::SERVICES_KEY]) && is_array($mailConfig[self::SERVICES_KEY])) {
+ $this->config = $mailConfig[self::SERVICES_KEY];
+ return $this->config;
+ }
+
+ foreach ($mailConfig as $key => $value) {
+ if ($key !== self::LOG_KEY && is_array($value)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Mail service "%s" must be configured under "%s.%s"; see the upgrade guide to version 6',
+ $key,
+ $this->configKey,
+ self::SERVICES_KEY
+ ));
+ }
}
return $this->config;
diff --git a/src/Factory/MailServiceAbstractFactory.php b/src/Factory/MailServiceAbstractFactory.php
index d104550..136566d 100644
--- a/src/Factory/MailServiceAbstractFactory.php
+++ b/src/Factory/MailServiceAbstractFactory.php
@@ -4,7 +4,6 @@
namespace Dot\Mail\Factory;
-use DirectoryIterator;
use Dot\Mail\Email;
use Dot\Mail\Event\MailEventListenerAwareInterface;
use Dot\Mail\Event\MailEventListenerInterface;
@@ -14,24 +13,19 @@
use Dot\Mail\Service\LogServiceInterface;
use Dot\Mail\Service\MailService;
use Dot\Mail\Service\MailServiceInterface;
-use FilesystemIterator;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
-use RecursiveDirectoryIterator;
-use RecursiveIteratorIterator;
-use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
+use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream;
use Symfony\Component\Mailer\Transport\TransportInterface;
use function explode;
use function gettype;
use function is_array;
-use function is_dir;
use function is_object;
use function is_string;
use function is_subclass_of;
-use function rawurlencode;
use function sprintf;
class MailServiceAbstractFactory extends AbstractMailFactory
@@ -61,45 +55,12 @@ public function __invoke(
)
);
- $logService = $container->get(LogServiceInterface::class);
- $message = $this->createMessage();
- $transport = $this->createTransport($container);
+ $logService = $container->get(LogServiceInterface::class);
+ $message = $this->createMessage();
+ $transport = $this->createTransport($container);
+ $attachments = $this->mailOptions->getMessageOptions()->getAttachments()->getFiles();
- $mailService = new MailService($logService, $message, $transport, $this->mailOptions);
-
- //set subject
- $mailService->setSubject($this->mailOptions->getMessageOptions()->getSubject());
-
- $body = $this->mailOptions->getMessageOptions()->getBody();
-
- $mailService->setBody($body->getContent(), $body->getCharset());
-
- //attach files
- $files = $this->mailOptions->getMessageOptions()->getAttachments()->getFiles();
- $mailService->addAttachments($files);
-
- //attach files from dir
- $dir = $this->mailOptions->getMessageOptions()->getAttachments()->getDir();
-
- if ($dir['iterate'] === true && is_string($dir['path']) && is_dir($dir['path'])) {
- $files = $dir['recursive'] === true
- ? new RecursiveIteratorIterator(
- new RecursiveDirectoryIterator(
- $dir['path'],
- FilesystemIterator::SKIP_DOTS
- ),
- RecursiveIteratorIterator::CHILD_FIRST
- )
- : new DirectoryIterator($dir['path']);
-
- foreach ($files as $fileInfo) {
- if ($fileInfo->isDir()) {
- continue;
- }
-
- $mailService->addAttachment($fileInfo->getPathname());
- }
- }
+ $mailService = new MailService($logService, $message, $transport, $this->mailOptions, $attachments);
$this->attachMailListeners($mailService, $container);
return $mailService;
@@ -135,6 +96,13 @@ protected function createMessage(): Email
$message->setBcc($bcc);
}
+ $message->subject($options->getSubject());
+
+ $body = $options->getBody();
+ if ($body->getContent() !== '') {
+ $message->html($body->getContent(), $body->getCharset());
+ }
+
return $message;
}
@@ -145,25 +113,32 @@ protected function createMessage(): Email
protected function createTransport(ContainerInterface $container): TransportInterface
{
$adapter = $this->mailOptions->getTransport();
+
+ //a transport object provided by the user is used as it is
if ($adapter instanceof TransportInterface) {
- return $this->setupTransportConfig($adapter);
+ return $adapter;
}
//check is adapter is a service
if ($container->has($adapter)) {
$transport = $container->get($adapter);
if ($transport instanceof TransportInterface) {
- return $this->setupTransportConfig($transport);
- } else {
- throw new InvalidArgumentException(
- 'Provided transport service does not return a ' . TransportInterface::class . ' instance'
- );
+ return $transport;
}
+
+ throw new InvalidArgumentException(
+ 'Provided transport service does not return a ' . TransportInterface::class . ' instance'
+ );
+ }
+
+ //the esmtp alias is configured from the smtp options
+ if ($adapter === EsmtpTransport::class) {
+ return $this->createEsmtpTransport();
}
//check is the adapter is Symfony default adapter
if (is_subclass_of($adapter, TransportInterface::class)) {
- return $this->setupTransportConfig(new $adapter());
+ return new $adapter();
}
//the adapter is not valid - throw exception
@@ -176,16 +151,35 @@ protected function createTransport(ContainerInterface $container): TransportInte
);
}
- protected function setupTransportConfig(TransportInterface $transport): TransportInterface
+ protected function createEsmtpTransport(): EsmtpTransport
{
- if ($transport instanceof EsmtpTransport) {
- $user = $this->mailOptions->getSmtpOptions()->getConnectionConfig()['username'];
- $pass = rawurlencode($this->mailOptions->getSmtpOptions()->getConnectionConfig()['password']);
- $tls = $this->mailOptions->getSmtpOptions()->getConnectionConfig()['tls'] === false ? 'false' : null;
- $port = $this->mailOptions->getSmtpOptions()->getPort();
- $host = $this->mailOptions->getSmtpOptions()->getHost();
- $transport = Transport::fromDsn('smtp://' . $user . ':' . $pass . '@' . $host . ':' . $port
- . '?auto_tls=' . $tls);
+ $smtp = $this->mailOptions->getSmtpOptions();
+ $connection = $smtp->getConnectionConfig();
+ $tls = $connection['tls'] ?? null;
+
+ //null leaves the encryption to the transport (automatic), false disables it
+ $transport = new EsmtpTransport($smtp->getHost(), $smtp->getPort(), $tls === false ? false : null);
+ $transport->setAutoTls($tls !== false);
+
+ $username = (string) ($connection['username'] ?? '');
+ if ($username !== '') {
+ $transport->setUsername($username);
+ }
+
+ $password = (string) ($connection['password'] ?? '');
+ if ($password !== '') {
+ $transport->setPassword($password);
+ }
+
+ $name = $smtp->getName();
+ if ($name !== null) {
+ $transport->setLocalDomain($name);
+ }
+
+ $timeLimit = $smtp->getConnectionTimeLimit();
+ $stream = $transport->getStream();
+ if ($timeLimit !== null && $stream instanceof SocketStream) {
+ $stream->setTimeout((float) $timeLimit);
}
return $transport;
diff --git a/src/Options/MailOptions.php b/src/Options/MailOptions.php
index 7da067c..171107b 100644
--- a/src/Options/MailOptions.php
+++ b/src/Options/MailOptions.php
@@ -21,7 +21,7 @@
class MailOptions extends AbstractOptions
{
protected array $eventListeners = [];
- protected TransportInterface|string $transport = EsmtpTransport::class;
+ protected TransportInterface|string $transport = SendmailTransport::class;
protected array $transportMap = [
'esmtp' => [EsmtpTransport::class],
'sendmail' => [SendmailTransport::class],
@@ -61,6 +61,9 @@ public function setTransport(string|TransportInterface $transport): void
public function getMessageOptions(): MessageOptions
{
+ if (! isset($this->messageOptions)) {
+ $this->messageOptions = new MessageOptions();
+ }
return $this->messageOptions;
}
@@ -71,6 +74,9 @@ public function setMessageOptions(array $messageOptions): void
public function getSmtpOptions(): SmtpOptions
{
+ if (! isset($this->smtpOptions)) {
+ $this->smtpOptions = new SmtpOptions();
+ }
return $this->smtpOptions;
}
diff --git a/src/Options/MessageOptions.php b/src/Options/MessageOptions.php
index 4076bad..ad1454f 100644
--- a/src/Options/MessageOptions.php
+++ b/src/Options/MessageOptions.php
@@ -105,6 +105,9 @@ public function setSubject(string $subject): void
public function getBody(): BodyOptions
{
+ if (! isset($this->body)) {
+ $this->body = new BodyOptions();
+ }
return $this->body;
}
@@ -115,6 +118,9 @@ public function setBody(array $body): void
public function getAttachments(): AttachmentsOptions
{
+ if (! isset($this->attachments)) {
+ $this->attachments = new AttachmentsOptions();
+ }
return $this->attachments;
}
diff --git a/src/Options/SmtpOptions.php b/src/Options/SmtpOptions.php
index be8ccf0..947e74c 100644
--- a/src/Options/SmtpOptions.php
+++ b/src/Options/SmtpOptions.php
@@ -14,22 +14,22 @@
*/
class SmtpOptions extends AbstractOptions
{
- protected string $name = 'localhost';
+ protected ?string $name = null;
protected array $connectionConfig = [];
protected string $host = '127.0.0.1';
protected int $port = 25;
- protected ?int $connectionTimeLimit;
+ protected ?int $connectionTimeLimit = null;
- public function getName(): string
+ public function getName(): ?string
{
return $this->name;
}
/**
- * Set the local client hostname or IP
+ * Set the local client hostname or IP; when null, the transport's own default is kept
*/
- public function setName(string $name): static
+ public function setName(?string $name): static
{
$this->name = $name;
return $this;
diff --git a/src/Result/MailResult.php b/src/Result/MailResult.php
index 07a6028..5de6e9a 100644
--- a/src/Result/MailResult.php
+++ b/src/Result/MailResult.php
@@ -4,7 +4,7 @@
namespace Dot\Mail\Result;
-use Exception;
+use Throwable;
class MailResult implements ResultInterface
{
@@ -12,12 +12,12 @@ class MailResult implements ResultInterface
protected bool $valid;
protected string $message;
- protected Exception|null $exception;
+ protected Throwable|null $exception;
public function __construct(
bool $valid = true,
string $message = self::DEFAULT_MESSAGE,
- ?Exception $exception = null
+ ?Throwable $exception = null
) {
$this->valid = $valid;
$this->message = $message;
@@ -36,10 +36,10 @@ public function isValid(): bool
public function hasException(): bool
{
- return $this->exception instanceof Exception;
+ return $this->exception instanceof Throwable;
}
- public function getException(): ?Exception
+ public function getException(): ?Throwable
{
return $this->exception;
}
diff --git a/src/Result/ResultInterface.php b/src/Result/ResultInterface.php
index 9e84413..3d56ebe 100644
--- a/src/Result/ResultInterface.php
+++ b/src/Result/ResultInterface.php
@@ -4,7 +4,7 @@
namespace Dot\Mail\Result;
-use Exception;
+use Throwable;
interface ResultInterface
{
@@ -14,5 +14,5 @@ public function isValid(): bool;
public function hasException(): bool;
- public function getException(): ?Exception;
+ public function getException(): ?Throwable;
}
diff --git a/src/Service/LogService.php b/src/Service/LogService.php
index 02e4290..890b80d 100644
--- a/src/Service/LogService.php
+++ b/src/Service/LogService.php
@@ -8,14 +8,15 @@
use Dot\Mail\Email;
use function dirname;
-use function file_exists;
use function file_put_contents;
+use function is_dir;
use function json_encode;
use function mkdir;
use function sprintf;
use function trim;
use const FILE_APPEND;
+use const LOCK_EX;
use const PHP_EOL;
class LogService implements LogServiceInterface
@@ -43,8 +44,8 @@ public function sent(Email $message): false|int|null
*/
$dirname = dirname($target);
- if (! file_exists($dirname)) {
- mkdir($dirname, 0755, true);
+ if (! is_dir($dirname) && ! @mkdir($dirname, 0755, true) && ! is_dir($dirname)) {
+ return false;
}
/**
@@ -63,7 +64,7 @@ public function sent(Email $message): false|int|null
/**
* Write the log data and return the result
*/
- return file_put_contents($target, $data, FILE_APPEND);
+ return file_put_contents($target, $data, FILE_APPEND | LOCK_EX);
}
public function extractAddresses(array $addressList): array
diff --git a/src/Service/MailService.php b/src/Service/MailService.php
index da943a0..524b971 100644
--- a/src/Service/MailService.php
+++ b/src/Service/MailService.php
@@ -4,6 +4,7 @@
namespace Dot\Mail\Service;
+use DirectoryIterator;
use Dot\Mail\Email;
use Dot\Mail\Event\MailEvent;
use Dot\Mail\Event\MailEventListenerAwareInterface;
@@ -12,19 +13,24 @@
use Dot\Mail\Options\MailOptions;
use Dot\Mail\Result\MailResult;
use Dot\Mail\Result\ResultInterface;
-use Exception;
-use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
+use FilesystemIterator;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Symfony\Component\Mime\Part\AbstractPart;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\MixedPart;
+use Throwable;
use function array_merge;
use function basename;
use function count;
-use function fopen;
+use function is_dir;
use function is_file;
+use function is_readable;
use function is_string;
+use function sprintf;
+use function str_starts_with;
class MailService implements MailServiceInterface, MailEventListenerAwareInterface
{
@@ -32,59 +38,81 @@ class MailService implements MailServiceInterface, MailEventListenerAwareInterfa
protected LogServiceInterface $logService;
protected Email $message;
+ protected Email $prototype;
protected TransportInterface $transport;
protected MailOptions $mailOptions;
- protected array $attachments = [];
+ protected array $attachments = [];
+ protected array $defaultAttachments = [];
+ /**
+ * @param array $attachments The default attachments, restored by reset() after every send
+ */
public function __construct(
LogServiceInterface $logService,
Email $message,
TransportInterface $transport,
- MailOptions $mailOptions
+ MailOptions $mailOptions,
+ array $attachments = []
) {
- $this->logService = $logService;
- $this->message = $message;
- $this->transport = $transport;
- $this->mailOptions = $mailOptions;
+ $this->logService = $logService;
+ $this->message = $message;
+ $this->prototype = clone $message;
+ $this->transport = $transport;
+ $this->mailOptions = $mailOptions;
+ $this->attachments = $attachments;
+ $this->defaultAttachments = $attachments;
}
/**
- * @throws MailException|TransportExceptionInterface
+ * Sends the current message, then restores the configured defaults (see reset()).
+ * Recipients, subject, body and attachments must be set again for every message.
+ * An exception thrown by a post-send listener propagates unchanged: the message was already delivered.
+ *
+ * @throws MailException
*/
public function send(): ResultInterface
{
$result = new MailResult();
- $this->message->setEncoding('utf-8');
+ if ($this->message->getEncoding() === null) {
+ $this->message->setEncoding(self::DEFAULT_CHARSET);
+ }
+
try {
$this->getEventManager()->triggerEvent($this->createMailEvent());
//attach files before sending
$this->attachFiles();
$this->getTransport()->send($this->getMessage());
- $this->getMessage()->setBody(null);
-
- $this->getEventManager()->triggerEvent($this->createMailEvent(MailEvent::EVENT_MAIL_POST_SEND, $result));
- } catch (Exception $e) {
+ } catch (Throwable $e) {
$result = $this->createMailResultFromException($e);
- //trigger error event
- $this->getEventManager()->triggerEvent($this->createMailEvent(MailEvent::EVENT_MAIL_SEND_ERROR, $result));
- throw new MailException($result->getMessage());
- } finally {
- // Reset the Symfony Message body after every send attempt (success or failure).
- // attachFiles() embeds DataParts into Message::$body; without this reset those
- // DataParts survive a failed send and get wrapped into the next email's body,
- // causing attachments from a failed send to leak into subsequent emails.
- $this->getMessage()->setBody(null);
+ try {
+ //trigger error event
+ $this->getEventManager()->triggerEvent(
+ $this->createMailEvent(MailEvent::EVENT_MAIL_SEND_ERROR, $result)
+ );
+ } finally {
+ $this->reset();
+ }
+ throw new MailException($result->getMessage(), (int) $e->getCode(), $e);
}
- if ($result->isValid()) {
- $this->logService->sent($this->getMessage());
+ try {
+ $this->logSent();
+ $this->getEventManager()->triggerEvent($this->createMailEvent(MailEvent::EVENT_MAIL_POST_SEND, $result));
+ } finally {
+ $this->reset();
}
return $result;
}
+ public function reset(): void
+ {
+ $this->message = clone $this->prototype;
+ $this->attachments = $this->defaultAttachments;
+ }
+
public function createMailEvent(
string $name = MailEvent::EVENT_MAIL_PRE_SEND,
?ResultInterface $result = null
@@ -96,26 +124,30 @@ public function createMailEvent(
return $event;
}
+ /**
+ * @throws MailException When an attachment does not exist or is not readable.
+ */
public function attachFiles(): false|Email
{
- if (count($this->attachments) === 0) {
+ $attachments = array_merge($this->attachments, $this->getDirectoryAttachments());
+ if (count($attachments) === 0) {
return false;
}
$body = $this->message->getBody();
$parts = [];
- foreach ($this->attachments as $key => $attachment) {
- if (! is_file($attachment)) {
- continue;
+ foreach ($attachments as $key => $attachment) {
+ if (! is_file($attachment) || ! is_readable($attachment)) {
+ throw new MailException(
+ sprintf('Attachment "%s" does not exist or is not readable', $attachment)
+ );
}
$basename = is_string($key) ? $key : basename($attachment);
- $parts[] = new DataPart(fopen($attachment, 'r'), $basename);
+ $parts[] = DataPart::fromPath($attachment, $basename);
}
- if (count($parts) > 0) {
- $this->message->setBody(new MixedPart($body, ...$parts));
- }
+ $this->message->setBody(new MixedPart($body, ...$parts));
return $this->message;
}
@@ -123,13 +155,13 @@ public function attachFiles(): false|Email
public function setBody(string|AbstractPart $body, ?string $charset = null): void
{
if (is_string($body)) {
- $this->message->html($body);
+ $this->message->html($body, $charset ?? self::DEFAULT_CHARSET);
} else {
$this->message->setBody($body);
}
}
- public function createMailResultFromException(Exception $e): ResultInterface
+ public function createMailResultFromException(Throwable $e): ResultInterface
{
return new MailResult(false, $e->getMessage(), $e);
}
@@ -177,4 +209,49 @@ public function setTransport(TransportInterface $transport): void
{
$this->transport = $transport;
}
+
+ /**
+ * A failed log write must never turn a delivered email into an error
+ */
+ protected function logSent(): void
+ {
+ try {
+ $this->logService->sent($this->getMessage());
+ } catch (Throwable) {
+ // intentionally ignored
+ }
+ }
+
+ /**
+ * Collects the files of the configured attachments directory at send time.
+ * Relative paths are resolved against the current working directory; dotfiles are skipped.
+ *
+ * @return string[]
+ */
+ protected function getDirectoryAttachments(): array
+ {
+ $dir = $this->mailOptions->getMessageOptions()->getAttachments()->getDir();
+
+ if (($dir['iterate'] ?? false) !== true || ! is_string($dir['path'] ?? null) || ! is_dir($dir['path'])) {
+ return [];
+ }
+
+ $files = ($dir['recursive'] ?? false) === true
+ ? new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($dir['path'], FilesystemIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST
+ )
+ : new DirectoryIterator($dir['path']);
+
+ $paths = [];
+ foreach ($files as $fileInfo) {
+ if ($fileInfo->isDir() || str_starts_with($fileInfo->getFilename(), '.')) {
+ continue;
+ }
+
+ $paths[] = $fileInfo->getPathname();
+ }
+
+ return $paths;
+ }
}
diff --git a/src/Service/MailServiceInterface.php b/src/Service/MailServiceInterface.php
index 5ebf83e..749579f 100644
--- a/src/Service/MailServiceInterface.php
+++ b/src/Service/MailServiceInterface.php
@@ -31,4 +31,10 @@ public function getAttachments(): array;
public function setAttachments(array $paths): void;
public function setTransport(TransportInterface $transport): void;
+
+ /**
+ * Restores the message and attachments to the configured defaults.
+ * Called automatically after every send() attempt.
+ */
+ public function reset(): void;
}
diff --git a/test/CommonTrait.php b/test/CommonTrait.php
index e42f8f7..26b831f 100644
--- a/test/CommonTrait.php
+++ b/test/CommonTrait.php
@@ -44,55 +44,57 @@ private function generateConfig(): array
{
return [
'dot_mail' => [
- 'default' => [
- 'message_options' => [
- 'from' => '',
- 'from_name' => '',
- 'reply_to' => '',
- 'reply_to_name' => '',
- 'to' => [],
- 'cc' => [],
- 'bcc' => [],
- 'subject' => '',
- 'body' => [
- 'content' => '',
- 'charset' => 'utf-8',
- ],
- 'attachments' => [
- 'files' => [],
- 'dir' => [
- 'iterate' => false,
- 'path' => $this->fileSystem->url() . '/data/mail/attachments',
- 'recursive' => false,
+ 'services' => [
+ 'default' => [
+ 'message_options' => [
+ 'from' => '',
+ 'from_name' => '',
+ 'reply_to' => '',
+ 'reply_to_name' => '',
+ 'to' => [],
+ 'cc' => [],
+ 'bcc' => [],
+ 'subject' => '',
+ 'body' => [
+ 'content' => '',
+ 'charset' => 'utf-8',
+ ],
+ 'attachments' => [
+ 'files' => [],
+ 'dir' => [
+ 'iterate' => false,
+ 'path' => $this->fileSystem->url() . '/data/mail/attachments',
+ 'recursive' => false,
+ ],
],
],
- ],
- /**
- * the mail transport to use
- * can be any class implementing Symfony\Component\Mailer\Transport\TransportInterface
- *
- * for standard mail transports, you can use these aliases
- * - sendmail => Symfony\Component\Mailer\Transport\SendmailTransport
- * - esmtp => Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport
- *
- * defaults to sendmail
- **/
- 'transport' => 'esmtp',
- //options that will be used only if Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport
- // adapter is used
- 'smtp_options' => [
- 'host' => 'testHost',
- 'port' => 587,
- 'connection_config' => [
- //the smtp authentication identity
- 'username' => 'test',
- //the smtp authentication credential
- 'password' => 'testPassword',
- 'tls' => null,
+ /**
+ * the mail transport to use
+ * can be any class implementing Symfony\Component\Mailer\Transport\TransportInterface
+ *
+ * for standard mail transports, you can use these aliases
+ * - sendmail => Symfony\Component\Mailer\Transport\SendmailTransport
+ * - esmtp => Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport
+ *
+ * defaults to sendmail
+ **/
+ 'transport' => 'esmtp',
+ //options that will be used only if Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport
+ // adapter is used
+ 'smtp_options' => [
+ 'host' => 'testHost',
+ 'port' => 587,
+ 'connection_config' => [
+ //the smtp authentication identity
+ 'username' => 'test',
+ //the smtp authentication credential
+ 'password' => 'testPassword',
+ 'tls' => null,
+ ],
],
],
+ 'test' => 'string test',
],
- 'test' => 'string test',
// option to log the SENT emails
'log' => [
'sent' => $this->fileSystem->url() . '/log/mail/sent.log',
diff --git a/test/ConfigProviderTest.php b/test/ConfigProviderTest.php
index cb4a0b8..8d7d4ce 100644
--- a/test/ConfigProviderTest.php
+++ b/test/ConfigProviderTest.php
@@ -10,6 +10,7 @@
use Dot\Mail\Factory\MailServiceAbstractFactory;
use Dot\Mail\Service\LogService;
use Dot\Mail\Service\LogServiceInterface;
+use Dot\Mail\Service\MailServiceInterface;
use PHPUnit\Framework\TestCase;
class ConfigProviderTest extends TestCase
@@ -42,6 +43,10 @@ public function testDependenciesHasAliases(): void
LogService::class,
$this->config['dependencies']['aliases'][LogServiceInterface::class]
);
+ $this->assertSame(
+ 'dot-mail.service.default',
+ $this->config['dependencies']['aliases'][MailServiceInterface::class]
+ );
}
public function testDependenciesHasAbstractFactories(): void
diff --git a/test/EmailTest.php b/test/EmailTest.php
index 3d4f871..5af708c 100644
--- a/test/EmailTest.php
+++ b/test/EmailTest.php
@@ -9,6 +9,8 @@
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Exception\LogicException;
+use Symfony\Component\Mime\Part\Multipart\AlternativePart;
+use Symfony\Component\Mime\Part\TextPart;
class EmailTest extends TestCase
{
@@ -85,4 +87,34 @@ public function testAddressObjectsInArraysAreKept(): void
$this->assertCount(1, $cc);
$this->assertSame('Copy', $cc[0]->getName());
}
+
+ public function testTextOnlyMessageHasOnlyTextPart(): void
+ {
+ $subject = new Email();
+ $subject->text('plain text');
+
+ $body = $subject->getBody();
+ $this->assertSame(TextPart::class, $body::class);
+ $this->assertSame('plain text', $body->getBody());
+ }
+
+ public function testTextAndHtmlMessageIsAlternative(): void
+ {
+ $subject = new Email();
+ $subject->text('plain text');
+ $subject->html('
html
'); + + $this->assertInstanceOf(AlternativePart::class, $subject->getBody()); + } + + public function testMessageWithoutBodyIsInvalid(): void + { + $subject = new Email(); + $subject->setFrom('from@example.com'); + $subject->setTo('to@example.com'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('A message must have a text or an HTML part.'); + $subject->ensureValidity(); + } } diff --git a/test/Event/MailEventTest.php b/test/Event/MailEventTest.php index d6323be..171191b 100644 --- a/test/Event/MailEventTest.php +++ b/test/Event/MailEventTest.php @@ -5,6 +5,7 @@ namespace DotTest\Mail\Event; use Dot\Mail\Event\MailEvent; +use Dot\Mail\Result\MailResult; use Dot\Mail\Service\MailServiceInterface; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\MockObject\MockObject; @@ -48,4 +49,14 @@ public function testEventPropagation(): void $event->stopPropagation(); $this->assertTrue($event->propagationIsStopped()); } + + public function testResultIsNullUntilSet(): void + { + $event = new MailEvent($this->mailService); + $this->assertNull($event->getResult()); + + $result = new MailResult(); + $event->setResult($result); + $this->assertSame($result, $event->getResult()); + } } diff --git a/test/Factory/AbstractMailFactoryTest.php b/test/Factory/AbstractMailFactoryTest.php index 319a6b5..dc958d2 100644 --- a/test/Factory/AbstractMailFactoryTest.php +++ b/test/Factory/AbstractMailFactoryTest.php @@ -4,6 +4,7 @@ namespace DotTest\Mail\Factory; +use Dot\Mail\Exception\InvalidArgumentException; use Dot\Mail\Factory\AbstractMailFactory; use Dot\Mail\Options\MailOptions; use DotTest\Mail\CommonTrait; @@ -102,4 +103,72 @@ public function testWillNotCreateRequestedNameWithoutParts(): void restore_error_handler(); } } + + /** + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testLogKeyIsNotACreatableService(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturn($this->config); + + $this->assertTrue($this->subject->canCreate($container, 'dot-mail.testPart.default')); + $this->assertFalse($this->subject->canCreate($container, 'dot-mail.testPart.log')); + } + + /** + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testServicesCanOnlyComeFromServicesKey(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturn([ + 'dot_mail' => [ + 'log' => ['sent' => '/tmp/sent.log'], + 'services' => ['log' => [], 'other' => []], + ], + ]); + + $this->assertTrue($this->subject->canCreate($container, 'dot-mail.testPart.other')); + $this->assertTrue($this->subject->canCreate($container, 'dot-mail.testPart.log')); + $this->assertFalse($this->subject->canCreate($container, 'dot-mail.testPart.missing')); + } + + /** + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testOldConfigurationLayoutIsRejected(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturn([ + 'dot_mail' => [ + 'default' => ['transport' => 'sendmail'], + 'log' => ['sent' => '/tmp/sent.log'], + ], + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Mail service "default" must be configured under "dot_mail.services"'); + $this->subject->canCreate($container, 'dot-mail.testPart.default'); + } + + /** + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testConfigurationWithOnlyLogCreatesNoService(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturn(['dot_mail' => ['log' => ['sent' => '/tmp/sent.log']]]); + + $this->assertFalse($this->subject->canCreate($container, 'dot-mail.testPart.log')); + $this->assertFalse($this->subject->canCreate($container, 'dot-mail.testPart.default')); + } } diff --git a/test/Factory/MailServiceAbstractFactoryTest.php b/test/Factory/MailServiceAbstractFactoryTest.php index 0519efb..bf62743 100644 --- a/test/Factory/MailServiceAbstractFactoryTest.php +++ b/test/Factory/MailServiceAbstractFactoryTest.php @@ -16,6 +16,7 @@ use Dot\Mail\Service\LogServiceInterface; use Dot\Mail\Service\MailService; use DotTest\Mail\CommonTrait; +use ErrorException; use org\bovigo\vfs\vfsStream; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\MockObject\MockObject; @@ -23,7 +24,12 @@ use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; +use Symfony\Component\Mailer\Transport\SendmailTransport; use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; +use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream; + +use function restore_error_handler; +use function set_error_handler; class MailServiceAbstractFactoryTest extends TestCase { @@ -119,10 +125,6 @@ public function testGenerateServiceWithProvidedAdapter(): void 'tls' => null, ]); - $this->smtpOptions->expects($this->once()) - ->method('getHost') - ->willReturn('localhost'); - $this->mailOptions->expects($this->any()) ->method('getSmtpOptions') ->willReturn($this->smtpOptions); @@ -187,10 +189,6 @@ public function testGenerateServiceWithoutProvidedAdapter(): void 'tls' => false, ]); - $this->smtpOptions->expects($this->once()) - ->method('getHost') - ->willReturn('localhost'); - $this->mailOptions->expects($this->any()) ->method('getSmtpOptions') ->willReturn($this->smtpOptions); @@ -262,4 +260,229 @@ public function testGenerateServiceThrowException(): void $this->expectException(InvalidArgumentException::class); ($this->subject)($this->container, $requestedName); } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testGenerateServiceFromMinimalConfiguration(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnMap([ + ['dot-mail.options.default', new MailOptions([])], + [LogServiceInterface::class, $this->createMock(LogService::class)], + ]); + + $mailService = ($this->subject)($container, 'dot-mail.service.default'); + + $this->assertInstanceOf(MailService::class, $mailService); + $this->assertInstanceOf(SendmailTransport::class, $mailService->getTransport()); + $this->assertNull($mailService->getMessage()->getHtmlBody()); + $this->assertSame([], $mailService->getAttachments()); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testGenerateServiceWithBodyContent(): void + { + $options = new MailOptions([ + 'transport' => 'sendmail', + 'message_options' => ['body' => ['content' => 'Hello
', 'charset' => 'iso-8859-1']], + ]); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnMap([ + ['dot-mail.options.default', $options], + [LogServiceInterface::class, $this->createMock(LogService::class)], + ]); + + $mailService = ($this->subject)($container, 'dot-mail.service.default'); + + $this->assertSame('Hello
', $mailService->getMessage()->getHtmlBody()); + $this->assertSame('iso-8859-1', $mailService->getMessage()->getHtmlCharset()); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testEsmtpTransportWithoutConnectionConfigKeys(): void + { + $options = new MailOptions([ + 'transport' => 'esmtp', + 'smtp_options' => ['host' => 'localhost', 'port' => 587], + ]); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnMap([ + ['dot-mail.options.default', $options], + [LogServiceInterface::class, $this->createMock(LogService::class)], + ]); + + set_error_handler(static function (int $severity, string $message): never { + throw new ErrorException($message, 0, $severity); + }); + + try { + $transport = ($this->subject)($container, 'dot-mail.service.default')->getTransport(); + } finally { + restore_error_handler(); + } + + $this->assertInstanceOf(EsmtpTransport::class, $transport); + $this->assertSame('', $transport->getUsername()); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testEsmtpCredentialsWithSpecialCharacters(): void + { + $options = new MailOptions([ + 'transport' => 'esmtp', + 'smtp_options' => [ + 'host' => 'localhost', + 'port' => 587, + 'connection_config' => ['username' => 'user@example.com', 'password' => 'p:ss/w@rd'], + ], + ]); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnMap([ + ['dot-mail.options.default', $options], + [LogServiceInterface::class, $this->createMock(LogService::class)], + ]); + + $transport = ($this->subject)($container, 'dot-mail.service.default')->getTransport(); + + $this->assertInstanceOf(EsmtpTransport::class, $transport); + $this->assertSame('user@example.com', $transport->getUsername()); + $this->assertSame('p:ss/w@rd', $transport->getPassword()); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testEsmtpTransportConfiguration(): void + { + $options = new MailOptions([ + 'transport' => 'esmtp', + 'smtp_options' => [ + 'host' => 'mail.example.com', + 'port' => 2525, + 'name' => 'client.example.com', + 'connection_time_limit' => 7, + 'connection_config' => ['username' => 'user', 'password' => 'secret'], + ], + ]); + + $transport = ($this->subject)($this->containerFor($options), 'dot-mail.service.default')->getTransport(); + + $this->assertInstanceOf(EsmtpTransport::class, $transport); + $this->assertSame('client.example.com', $transport->getLocalDomain()); + $stream = $transport->getStream(); + $this->assertInstanceOf(SocketStream::class, $stream); + $this->assertSame(7.0, $stream->getTimeout()); + $this->assertTrue($transport->isAutoTls()); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testEsmtpTlsSettings(): void + { + $cases = [ + 'automatic on 587' => [587, null, true, false], + 'implicit on 465' => [465, null, true, true], + 'disabled' => [587, false, false, false], + ]; + + foreach ($cases as $label => [$port, $tls, $autoTls, $implicitTls]) { + $options = new MailOptions([ + 'transport' => 'esmtp', + 'smtp_options' => [ + 'host' => 'mail.example.com', + 'port' => $port, + 'connection_config' => ['tls' => $tls], + ], + ]); + + $transport = ($this->subject)($this->containerFor($options), 'dot-mail.service.default')->getTransport(); + + $this->assertInstanceOf(EsmtpTransport::class, $transport); + $this->assertSame($autoTls, $transport->isAutoTls(), $label); + $this->assertInstanceOf(SocketStream::class, $transport->getStream()); + $this->assertSame($implicitTls, $transport->getStream()->isTLS(), $label); + } + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testProvidedTransportObjectIsUsedAsGiven(): void + { + $provided = new EsmtpTransport('provided.example.com', 25, false); + $options = new MailOptions([ + 'transport' => $provided, + 'smtp_options' => ['host' => 'ignored.example.com', 'port' => 587], + ]); + + $transport = ($this->subject)($this->containerFor($options), 'dot-mail.service.default')->getTransport(); + + $this->assertSame($provided, $transport); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testServiceKeepsConfiguredDefaultsAfterReset(): void + { + $options = new MailOptions([ + 'transport' => 'sendmail', + 'message_options' => [ + 'from' => 'from@example.com', + 'subject' => 'Default subject', + 'body' => ['content' => 'Default
'], + 'attachments' => ['files' => ['/some/default/file.txt']], + ], + ]); + + $mailService = ($this->subject)($this->containerFor($options), 'dot-mail.service.default'); + $mailService->getMessage()->addTo('someone@example.com'); + $mailService->addAttachment('/another/file.txt'); + + $mailService->reset(); + + $this->assertSame('Default subject', $mailService->getMessage()->getSubject()); + $this->assertSame('Default
', $mailService->getMessage()->getHtmlBody()); + $this->assertSame('from@example.com', $mailService->getMessage()->getFrom()[0]->getAddress()); + $this->assertSame([], $mailService->getMessage()->getTo()); + $this->assertSame(['/some/default/file.txt'], $mailService->getAttachments()); + } + + private function containerFor(MailOptions $options): ContainerInterface + { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnMap([ + ['dot-mail.options.default', $options], + [LogServiceInterface::class, $this->createMock(LogService::class)], + ]); + + return $container; + } } diff --git a/test/Options/MailOptionsTest.php b/test/Options/MailOptionsTest.php index ab427c7..29de436 100644 --- a/test/Options/MailOptionsTest.php +++ b/test/Options/MailOptionsTest.php @@ -50,4 +50,14 @@ public function testTransportAliasIsCaseInsensitive(): void $subject->setTransport('Custom\\Transport'); $this->assertSame('Custom\\Transport', $subject->getTransport()); } + + public function testDefaultsWithoutConfiguration(): void + { + $subject = new MailOptions(); + + $this->assertSame(SendmailTransport::class, $subject->getTransport()); + $this->assertContainsOnlyInstancesOf(MessageOptions::class, [$subject->getMessageOptions()]); + $this->assertContainsOnlyInstancesOf(SmtpOptions::class, [$subject->getSmtpOptions()]); + $this->assertSame($subject->getMessageOptions(), $subject->getMessageOptions()); + } } diff --git a/test/Options/MessageOptionsTest.php b/test/Options/MessageOptionsTest.php index 7a68400..42d31a5 100644 --- a/test/Options/MessageOptionsTest.php +++ b/test/Options/MessageOptionsTest.php @@ -48,4 +48,14 @@ public function testGettersAndSetters(): void $this->assertContainsOnlyInstancesOf(BodyOptions::class, [$subject->getBody()]); $this->assertContainsOnlyInstancesOf(AttachmentsOptions::class, [$subject->getAttachments()]); } + + public function testDefaultsWithoutConfiguration(): void + { + $subject = new MessageOptions(); + + $this->assertContainsOnlyInstancesOf(BodyOptions::class, [$subject->getBody()]); + $this->assertSame('', $subject->getBody()->getContent()); + $this->assertContainsOnlyInstancesOf(AttachmentsOptions::class, [$subject->getAttachments()]); + $this->assertSame([], $subject->getAttachments()->getFiles()); + } } diff --git a/test/Options/SmtpOptionsTest.php b/test/Options/SmtpOptionsTest.php index 81cdc08..d24e155 100644 --- a/test/Options/SmtpOptionsTest.php +++ b/test/Options/SmtpOptionsTest.php @@ -41,4 +41,9 @@ public function testPortBoundaries(): void $this->expectExceptionMessage('Port must be greater than 0; received "0"'); $subject->setPort(0); } + + public function testConnectionTimeLimitDefaultsToNull(): void + { + $this->assertNull((new SmtpOptions())->getConnectionTimeLimit()); + } } diff --git a/test/Result/MailResultTest.php b/test/Result/MailResultTest.php index 94c90bf..e5e44b0 100644 --- a/test/Result/MailResultTest.php +++ b/test/Result/MailResultTest.php @@ -7,6 +7,7 @@ use Dot\Mail\Result\MailResult; use Exception; use PHPUnit\Framework\TestCase; +use TypeError; class MailResultTest extends TestCase { @@ -27,4 +28,14 @@ public function testGettersAndSetters(): void $this->assertSame($exception->getMessage(), $mailResult->getException()->getMessage()); $this->assertTrue($mailResult->hasException()); } + + public function testResultCanHoldAnyThrowable(): void + { + $error = new TypeError('Type error test'); + + $mailResult = new MailResult(false, $error->getMessage(), $error); + + $this->assertTrue($mailResult->hasException()); + $this->assertSame($error, $mailResult->getException()); + } } diff --git a/test/Service/LogServiceTest.php b/test/Service/LogServiceTest.php index e2bfe79..8692d20 100644 --- a/test/Service/LogServiceTest.php +++ b/test/Service/LogServiceTest.php @@ -83,4 +83,24 @@ public function testSentMailIsLogged(): void file_get_contents($this->fileSystem->url() . '/log/mail/sent.log') ); } + + /** + * @throws Exception + */ + public function testSentReturnsFalseWhenLogDirectoryCannotBeCreated(): void + { + // the "directory" of the target is an existing file, so it can be neither used nor created + $target = $this->fileSystem->url() . '/data/mail/attachments/testPdfAttachment.pdf/sent.log'; + $logService = new LogService(['log' => ['sent' => $target]]); + $message = $this->createMock(Email::class); + + $this->assertFalse($logService->sent($message)); + } + + public function testSentIsDisabledWithoutTarget(): void + { + $logService = new LogService(['log' => ['sent' => null]]); + + $this->assertNull($logService->sent(new Email())); + } } diff --git a/test/Service/MailServiceTest.php b/test/Service/MailServiceTest.php index 685711f..22f4ae6 100644 --- a/test/Service/MailServiceTest.php +++ b/test/Service/MailServiceTest.php @@ -5,6 +5,7 @@ namespace DotTest\Mail\Service; use Dot\Mail\Email; +use Dot\Mail\Event\AbstractMailEventListener; use Dot\Mail\Event\MailEvent; use Dot\Mail\Exception\MailException; use Dot\Mail\Exception\RuntimeException; @@ -25,6 +26,12 @@ use Symfony\Component\Mime\Part\DataPart; use Symfony\Component\Mime\Part\Multipart\MixedPart; use Symfony\Component\Mime\Part\TextPart; +use Throwable; +use TypeError; + +use function file_put_contents; +use function mkdir; +use function sort; class MailServiceTest extends TestCase { @@ -209,23 +216,18 @@ public function testAttachFilesSingleAttachmentIsFlat(): void $this->assertInstanceOf(DataPart::class, $parts[1]); } - public function testAttachFilesSkipsNonExistentFiles(): void + public function testAttachFilesThrowsOnMissingFile(): void { $this->message->html('Test
'); - $this->mailService->addAttachment('/nonexistent/file.pdf'); $this->mailService->addAttachment( $this->fileSystem->url() . '/data/mail/attachments/testPdfAttachment.pdf' ); + $this->mailService->addAttachment('/nonexistent/file.pdf'); + $this->expectException(MailException::class); + $this->expectExceptionMessage('Attachment "/nonexistent/file.pdf" does not exist or is not readable'); $this->mailService->attachFiles(); - - $body = $this->message->getBody(); - $this->assertInstanceOf(MixedPart::class, $body); - - $parts = $body->getParts(); - // only 1 valid attachment + the html body - $this->assertCount(2, $parts); } public function testAttachFilesPreservesCustomFilename(): void @@ -299,11 +301,10 @@ public function testAttachFilesNoNestedMixedParts(): void /** * Found an attachment leak where DataParts * embedded into Message::$body by attachFiles() survived a failed send - * and were wrapped into the next email. The fix must live in dot-mail: - * after send() (success OR failure) the underlying Message::$body must - * be reset so the next send rebuilds the body from scratch. + * and were wrapped into the next email. After send() (success OR failure) + * the service must hand out a clean message built from the configured defaults. */ - public function testMessageBodyIsResetAfterFailedSendToPreventAttachmentLeak(): void + public function testMessageIsResetAfterFailedSendToPreventAttachmentLeak(): void { $this->mailService->setSubject('First'); $this->message->html('First body'); @@ -322,11 +323,12 @@ public function testMessageBodyIsResetAfterFailedSendToPreventAttachmentLeak(): // expected } + $this->assertNotSame($this->message, $this->mailService->getMessage()); $this->assertNull( - $this->readPrivateMessageBody($this->message), - 'After a failed send the Symfony Message::$body must be reset; ' - . 'otherwise attachFiles() will wrap the leaked MixedPart into the next email.' + $this->readPrivateMessageBody($this->mailService->getMessage()), + 'After a failed send the message must be rebuilt from the defaults, without the attached body.' ); + $this->assertSame([], $this->mailService->getAttachments()); } /** @@ -362,10 +364,9 @@ public function testAttachmentsDoNotLeakIntoSubsequentSendAfterFailure(): void // expected } - // Reconfigure: brand new email with NO attachments. - $this->mailService->setAttachments([]); + // Configure a brand new email; the attachment of the first one must be gone. $this->mailService->setSubject('Second'); - $this->message->html('Second email - must not carry first email\'s attachment'); + $this->mailService->getMessage()->html('Second email - must not carry first email\'s attachment'); $this->mailService->send(); @@ -381,13 +382,14 @@ public function testAttachmentsDoNotLeakIntoSubsequentSendAfterFailure(): void } /** - * Confirms the success path also resets the body, so a future refactor + * Confirms the success path also resets the message, so a future refactor * does not silently regress the success case. */ - public function testMessageBodyIsResetAfterSuccessfulSend(): void + public function testMessageIsResetAfterSuccessfulSend(): void { $this->mailService->setSubject('Hello'); $this->message->html('Body'); + $this->message->addTo('first@example.com'); $this->mailService->addAttachment( $this->fileSystem->url() . '/data/mail/attachments/testPdfAttachment.pdf' ); @@ -396,17 +398,200 @@ public function testMessageBodyIsResetAfterSuccessfulSend(): void $this->mailService->send(); - $this->assertNull( - $this->readPrivateMessageBody($this->message), - 'Message body should be null after a successful send so the next email starts clean.' + $message = $this->mailService->getMessage(); + $this->assertNotSame($this->message, $message); + $this->assertNull($this->readPrivateMessageBody($message)); + $this->assertNull($message->getSubject()); + $this->assertSame([], $message->getTo()); + $this->assertSame([], $this->mailService->getAttachments()); + } + + public function testResetRestoresConfiguredDefaults(): void + { + $defaults = new Email(); + $defaults->subject('Default subject'); + $defaults->addFrom('from@example.com'); + + $mailService = new MailService( + $this->createMock(LogServiceInterface::class), + $defaults, + $this->transportInterface, + $this->createMock(MailOptions::class), + ['/default/attachment.txt'] + ); + + $mailService->setSubject('Changed'); + $mailService->getMessage()->addTo('someone@example.com'); + $mailService->addAttachment('/extra/attachment.txt'); + + $mailService->reset(); + + $this->assertSame('Default subject', $mailService->getMessage()->getSubject()); + $this->assertSame('from@example.com', $mailService->getMessage()->getFrom()[0]->getAddress()); + $this->assertSame([], $mailService->getMessage()->getTo()); + $this->assertSame(['/default/attachment.txt'], $mailService->getAttachments()); + } + + public function testListenersSeeTheFullMessageBeforeTheReset(): void + { + $listener = new class extends AbstractMailEventListener { + public array $seen = []; + + public function onPostSend(MailEvent $e): void + { + $this->seen[] = $e->getMailService()->getMessage()->getSubject(); + } + }; + $this->mailService->attachListener($listener); + $this->mailService->setSubject('Visible to listeners'); + + $this->mailService->send(); + + $this->assertSame(['Visible to listeners'], $listener->seen); + $this->assertNull($this->mailService->getMessage()->getSubject()); + } + + public function testSendFailureKeepsThePreviousException(): void + { + $exception = new RuntimeException('Transport failure', 42); + $this->transportInterface->method('send')->willThrowException($exception); + + try { + $this->mailService->send(); + $this->fail('Expected MailException was not thrown'); + } catch (MailException $e) { + $this->assertSame($exception, $e->getPrevious()); + $this->assertSame(42, $e->getCode()); + $this->assertSame('Transport failure', $e->getMessage()); + } + } + + public function testErrorsAreReportedAsSendErrors(): void + { + $listener = new class extends AbstractMailEventListener { + public array $events = []; + + public function onSendError(MailEvent $e): void + { + $this->events[] = $e->getResult()?->getException()::class; + } + }; + $this->mailService->attachListener($listener); + $this->transportInterface->method('send')->willThrowException(new TypeError('Type problem')); + + try { + $this->mailService->send(); + $this->fail('Expected MailException was not thrown'); + } catch (MailException $e) { + $this->assertInstanceOf(TypeError::class, $e->getPrevious()); + } + + $this->assertSame([TypeError::class], $listener->events); + } + + public function testFailingPostSendListenerIsNotReportedAsSendError(): void + { + $listener = new class extends AbstractMailEventListener { + public int $errors = 0; + + public function onPostSend(MailEvent $e): void + { + throw new RuntimeException('Listener failure'); + } + + public function onSendError(MailEvent $e): void + { + $this->errors++; + } + }; + $this->mailService->attachListener($listener); + $this->mailService->setSubject('Delivered'); + + $caught = null; + try { + $this->mailService->send(); + } catch (Throwable $e) { + $caught = $e; + } + + $this->assertInstanceOf(RuntimeException::class, $caught); + $this->assertSame('Listener failure', $caught->getMessage()); + $this->assertSame(0, $listener->errors); + $this->assertNull($this->mailService->getMessage()->getSubject(), 'The message must be reset anyway'); + } + + public function testDirectoryAttachmentsAreCollectedAtSendTime(): void + { + $dir = $this->fileSystem->url() . '/data/mail/attachments'; + file_put_contents($dir . '/.gitkeep', ''); + mkdir($dir . '/nested'); + file_put_contents($dir . '/nested/nested.txt', 'nested'); + + $options = new MailOptions([ + 'message_options' => [ + 'attachments' => ['dir' => ['iterate' => true, 'path' => $dir, 'recursive' => false]], + ], + ]); + + $message = new Email(); + $mailService = new MailService( + $this->createMock(LogServiceInterface::class), + $message, + $this->transportInterface, + $options ); + $message->html('Test
'); + + // added after the service was created + file_put_contents($dir . '/late.txt', 'late'); + + $mailService->attachFiles(); + + $body = $message->getBody(); + $this->assertInstanceOf(MixedPart::class, $body); + $names = []; + foreach ($body->getParts() as $part) { + if ($part instanceof DataPart) { + $names[] = $part->getFilename(); + } + } + sort($names); + + $this->assertSame(['late.txt', 'testPdfAttachment.pdf', 'testXlsAttachment.xls'], $names); + } + + public function testRecursiveDirectoryAttachments(): void + { + $dir = $this->fileSystem->url() . '/data/mail/attachments'; + mkdir($dir . '/nested'); + file_put_contents($dir . '/nested/nested.txt', 'nested'); + + $options = new MailOptions([ + 'message_options' => [ + 'attachments' => ['dir' => ['iterate' => true, 'path' => $dir, 'recursive' => true]], + ], + ]); + + $message = new Email(); + $mailService = new MailService( + $this->createMock(LogServiceInterface::class), + $message, + $this->transportInterface, + $options + ); + $message->html('Test
'); + + $mailService->attachFiles(); + + $body = $message->getBody(); + $this->assertInstanceOf(MixedPart::class, $body); + $this->assertCount(4, $body->getParts()); } private function readPrivateMessageBody(Message $message): ?AbstractPart { $reflection = new ReflectionClass(Message::class); $property = $reflection->getProperty('body'); - $property->setAccessible(true); return $property->getValue($message); } @@ -427,4 +612,73 @@ private function bodyContainsDataPart(AbstractPart $part): bool return false; } + + public function testSetBodyUsesProvidedCharset(): void + { + $this->mailService->setBody('Body
', 'iso-8859-1'); + + $this->assertSame('iso-8859-1', $this->message->getHtmlCharset()); + } + + public function testSetBodyDefaultsToUtf8(): void + { + $this->mailService->setBody('Body
'); + + $this->assertSame('utf-8', $this->message->getHtmlCharset()); + } + + public function testSendKeepsConfiguredEncoding(): void + { + $encoding = null; + $this->transportInterface->method('send')->willReturnCallback( + function (Email $email) use (&$encoding) { + $encoding = $email->getEncoding(); + return null; + } + ); + + $this->mailService->setBody('Body
', 'iso-8859-1'); + $this->mailService->send(); + + $this->assertSame('iso-8859-1', $encoding); + } + + public function testSendSetsDefaultEncodingWhenNoneIsSet(): void + { + $encoding = 'not called'; + $this->transportInterface->method('send')->willReturnCallback( + function (Email $email) use (&$encoding) { + $encoding = $email->getEncoding(); + return null; + } + ); + + $this->assertNull($this->message->getEncoding()); + + $this->mailService->send(); + + $this->assertSame('utf-8', $encoding); + } + + /** + * @throws Exception + */ + public function testLogFailureDoesNotBreakSuccessfulSend(): void + { + $logService = $this->createMock(LogServiceInterface::class); + $logService->expects($this->once()) + ->method('sent') + ->willThrowException(new RuntimeException('Log failure')); + + $mailService = new MailService( + $logService, + new Email(), + $this->transportInterface, + $this->createMock(MailOptions::class) + ); + + $result = $mailService->send(); + + $this->assertTrue($result->isValid()); + } }