Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 47 additions & 16 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,69 @@ version: 2.1
workflows:
php-tests:
jobs:
# The only job that needs no Postmark credentials, so it is the only one that
# gives a fork PR any signal at all. Everything below is an integration suite
# against the live API and cannot start without the tokens above.
# Static analysis needs no Postmark credentials, so it gives a fork PR real signal.
# The unit-tests jobs below now run the CREDENTIAL-FREE suite on each supported PHP
# version, so a red one means the code is broken on that version -- which is the whole
# point. The live-API suite runs separately (integration-tests) and skips itself with a
# message when no tokens are configured, rather than erroring 79 times and making a
# credential outage look identical to a mass regression. That is what kept this pipeline
# red from 2025-10 onward with no code change behind it.
- static-analysis:
name: static
- unit-tests:
name: php81
version: "8.1"
name: static-guzzle8
guzzle: "^8.0.1"
# The constraint allows two Guzzle majors, so both have to be exercised or
# the compat claim is just an assertion. Guzzle 8 reclassified transport
# exceptions, which is the surface this SDK re-exports.
- static-analysis:
name: static-guzzle7
guzzle: "^7.15.2"
- unit-tests:
name: php82
version: "8.2"
requires:
- php81
- unit-tests:
name: php83
version: "8.3"
requires:
- php82
- unit-tests:
name: php84
version: "8.4"
requires:
- php83
- unit-tests:
name: php85
version: "8.5"
- integration-tests:
name: integration

jobs:
static-analysis:
# Live-API suite. Green-with-skips where no tokens are configured, a real gate where they are.
# Deliberately NOT chained to the unit jobs: chaining php82->83->84->85 is what hid three
# versions' results behind one failure.
integration-tests:
docker:
- image: cimg/php:8.1
- image: cimg/php:8.2
steps:
- checkout
- run:
name: Install dependencies
command: composer install --no-interaction
- run:
name: Run integration tests (skips without credentials)
command: composer test:integration

static-analysis:
parameters:
guzzle:
description: "Guzzle constraint to resolve against"
type: string
default: "^8.0.1"
docker:
- image: cimg/php:8.2
steps:
- checkout
- run:
name: Install dependencies (Guzzle << parameters.guzzle >>)
command: |
composer require --no-update --no-interaction "guzzlehttp/guzzle:<< parameters.guzzle >>"
composer update --no-interaction --with-all-dependencies
composer show guzzlehttp/guzzle | grep '^versions'
- run:
name: PHPStan
command: vendor/bin/phpstan analyse --memory-limit=1G --no-progress
Expand Down Expand Up @@ -71,5 +101,6 @@ jobs:
sudo composer self-update
sudo composer install --no-interaction
- run:
name: Run tests
# Credential-free suite: real per-version signal that can actually be green.
name: Run unit tests
command: composer test
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,60 @@ you were catching the `TypeError` from any of the getters above as a workaround,
- CI gained a credential-free `static-analysis` job running PHPStan, which is the only check in
this repo a fork PR can currently exercise.

## [Unreleased] — v8.0.0 (breaking)

### Removed
- **Dropped support for PHP 8.1** (EOL 2025-12-31). `composer.json` now requires `^8.2`.
Projects on 8.1 stay on v7.x — Composer will not offer them this release.
Note the previous `~8.1 || ~8.2 || ~8.3 || ~8.4` already resolved to `>=8.1 <9.0`, so 8.5 was
always permitted; dropping 8.1 is the only real constraint change.

### Changed
- **BREAKING** — `PostmarkAttachment::fromRawData()`, `::fromBase64EncodedData()` and `::fromFile()`
now declare `string` for their first two parameters and a `PostmarkAttachment` return type.
Passing `null`, an array, or a non-Stringable object now raises a `TypeError`; previously it
silently produced an empty attachment. `int` and `Stringable` still coerce, except under
`declare(strict_types=1)`. **Subclasses overriding these factories must add the
`: PostmarkAttachment` return type or PHP will fatal at class-load.**
- **BREAKING** — `PostmarkAttachment::fromFile()` now throws `RuntimeException` when the file
cannot be read, instead of sending an attachment with empty content.

### Added
- PHP 8.5 to the CI matrix.
- **Guzzle 8 is now supported** alongside Guzzle 7 (`^7.15.2 || ^8.0.1`), thanks to
[@simPod](https://github.com/simPod) (#165). Both majors are exercised in CI rather than
assumed compatible. The floors are deliberate: Guzzle 8.0.0 and 7.x below 7.15.2 carry
[GHSA-v5mv-p594-2x33](https://github.com/advisories/GHSA-v5mv-p594-2x33) (high, host-check
bypass) and [GHSA-f7vp-7xgx-4w4r](https://github.com/advisories/GHSA-f7vp-7xgx-4w4r).

**Read this if you catch Guzzle exceptions.** Composer resolves the highest satisfying
version, so upgrading puts you on Guzzle 8 unless you pin otherwise — this is not opt-in.
Guzzle 8 reclassified transport exceptions, and because this SDK sets `http_errors => false`
and maps responses to `PostmarkException` itself, the transport family is the *only* Guzzle
family that reaches your code. Most notably a plain timeout is no longer a `ConnectException`:

| cURL condition | Guzzle 7 | Guzzle 8 |
| --- | --- | --- |
| timeout, connect phase | `ConnectException` | `ConnectTimeoutException` (extends `ConnectException`) |
| timeout, no response | `ConnectException` | **`NetworkTimeoutException`** |
| timeout, body stalled | `ConnectException` | **`ResponseTimeoutException`** |
| send/recv error | `RequestException` | **`NetworkException`** |

Everything still implements `GuzzleException`, so the SDK's documented contract is unchanged —
but `catch (ConnectException $e)` around a send will silently stop matching a timeout.

### Fixed
- **`getDeliveryStatistics()` reported `Count = 0` for every bounce category, in every released
version.** `PostmarkBounceSummary` read the `FirstOpen` key instead of `Count` — a copy-paste
from `PostmarkOpen`. Any dashboard calibrated against the broken zero will start seeing real
numbers.
- `PostmarkBounce` assigned its constructor fallbacks to the wrong properties (`Type` got `0`,
`TypeCode` got `''`), throwing `TypeError` on a response missing either field.
- List models no longer emit `Undefined array key` / `foreach() argument must be of type
array|object` warnings when the API response omits the collection key. These were fatal under
application error handlers that promote warnings to exceptions (Laravel, Symfony).


## [v7.0.0](https://github.com/ActiveCampaign/postmark-php/tree/v7.0.0)

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ With Postmark, you can send and _receive_ emails effortlessly.

## Requirements

- PHP 8.1, 8.2, 8.3, or 8.4
- PHP 8.2, 8.3, 8.4, or 8.5
- Guzzle HTTP client

## Getting Started
Expand Down
8 changes: 5 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
"license": "MIT",
"description": "The officially supported client for Postmark (https://postmarkapp.com)",
"require": {
"php": "~8.1 || ~8.2|| ~8.3 || ~8.4",
"guzzlehttp/guzzle": "^7.8"
"php": "^8.2",
"guzzlehttp/guzzle": "^7.15.2 || ^8.0.1"
},
"require-dev": {
"phpunit/phpunit": "^10.0",
Expand All @@ -29,7 +29,9 @@
]
},
"scripts": {
"test": "phpunit"
"test": "phpunit --testsuite unit",
"test:integration": "phpunit --testsuite integration",
"test:all": "phpunit"
},
"config": {
"allow-plugins": {
Expand Down
26 changes: 19 additions & 7 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
<phpunit
colors="true"
processIsolation="false"
stopOnFailure="false"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true">
processIsolation="false"
stopOnFailure="false"
cacheDirectory=".phpunit.cache"
displayDetailsOnSkippedTests="true">
<!-- Two real suites. The single suite that used to be here was named "unit" but included all of
tests/, which is ~95% integration tests against the live API — so "unit" reported nothing of
the sort, and a credential outage looked identical to a mass regression. -->
<testsuites>
<testsuite name="unit">
<!-- Credential-free: constructs models from the array shapes the API returns. Safe on a
fork PR, and the only suite whose red means "the code is broken". -->
<file>tests/NullableGetterRegressionTest.php</file>
</testsuite>
<testsuite name="integration">
<!-- Hits the live Postmark API. Skips itself with a message when no tokens are configured
(PostmarkClientBaseTest), so this is green-with-skips rather than red in an
environment that cannot run it. -->
<directory>tests/</directory>
<exclude>tests/PostmarkClientBaseTest.php</exclude>
<exclude>tests/TestingKeys.php</exclude>
<exclude>tests/NullableGetterRegressionTest.php</exclude>
</testsuite>
</testsuites>

<logging>
<junit outputFile="build/unit_report.xml"/>
</logging>
</phpunit>
</phpunit>
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public function __construct(array $values)
{
$this->TotalCount = !empty($values['TotalCount']) ? $values['TotalCount'] : 0;
$tempMessageStreams = [];
foreach ($values['MessageStreams'] as $open) {
foreach ($values['MessageStreams'] ?? [] as $open) {
$obj = json_decode(json_encode($open));
$postmarkMessageStreams = new PostmarkMessageStream((array) $obj);

Expand Down
20 changes: 16 additions & 4 deletions src/Postmark/Models/PostmarkAttachment.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,31 @@ private function __construct($base64EncodedData, $attachmentName, $mimeType = 'a
$this->contentId = $contentId;
}

public static function fromRawData($data, $attachmentName, ?string $mimeType = null, ?string $contentId = null)
public static function fromRawData(string $data, string $attachmentName, ?string $mimeType = null, ?string $contentId = null): PostmarkAttachment
{
return new PostmarkAttachment(base64_encode($data), $attachmentName, $mimeType, $contentId);
}

public static function fromBase64EncodedData($base64EncodedData, $attachmentName, ?string $mimeType = null, ?string $contentId = null)
public static function fromBase64EncodedData(string $base64EncodedData, string $attachmentName, ?string $mimeType = null, ?string $contentId = null): PostmarkAttachment
{
return new PostmarkAttachment($base64EncodedData, $attachmentName, $mimeType, $contentId);
}

public static function fromFile($filePath, $attachmentName, ?string $mimeType = null, ?string $contentId = null)
/**
* @throws \RuntimeException if the file cannot be read
*/
public static function fromFile(string $filePath, string $attachmentName, ?string $mimeType = null, ?string $contentId = null): PostmarkAttachment
{
return new PostmarkAttachment(base64_encode(file_get_contents($filePath)), $attachmentName, $mimeType, $contentId);
// file_get_contents() returns false on failure and base64_encode(false) is "",
// so an unreadable path previously produced a silently empty attachment that
// still went out with the message.
$contents = @file_get_contents($filePath);

if (false === $contents) {
throw new \RuntimeException(sprintf('Unable to read attachment file "%s".', $filePath));
}

return new PostmarkAttachment(base64_encode($contents), $attachmentName, $mimeType, $contentId);
}

#[ReturnTypeWillChange]
Expand Down
4 changes: 2 additions & 2 deletions src/Postmark/Models/PostmarkBounce.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ public function __construct(array $values)
{
$this->RecordType = !empty($values['RecordType']) ? $values['RecordType'] : '';
$this->ID = !empty($values['ID']) ? $values['ID'] : 0;
$this->Type = !empty($values['Type']) ? $values['Type'] : 0;
$this->TypeCode = !empty($values['TypeCode']) ? $values['TypeCode'] : '';
$this->Type = !empty($values['Type']) ? $values['Type'] : '';
$this->TypeCode = !empty($values['TypeCode']) ? $values['TypeCode'] : 0;
$this->Name = !empty($values['Name']) ? $values['Name'] : '';
$this->Tag = !empty($values['Tag']) ? $values['Tag'] : '';
$this->MessageID = !empty($values['MessageID']) ? $values['MessageID'] : '';
Expand Down
3 changes: 2 additions & 1 deletion src/Postmark/Models/PostmarkBounceList.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public function __construct(array $values)
{
$this->TotalCount = !empty($values['TotalCount']) ? $values['TotalCount'] : 0;
$tempBounce = [];
foreach ($values['Bounces'] as $bounce) {
$bounces = $values['Bounces'] ?? [];
foreach ($bounces as $bounce) {
$obj = json_decode(json_encode($bounce));
$postmarkBounce = new PostmarkBounce((array) $obj);

Expand Down
2 changes: 1 addition & 1 deletion src/Postmark/Models/PostmarkBounceSummary.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public function __construct(array $values)
{
$this->Type = !empty($values['Type']) ? $values['Type'] : '';
$this->Name = !empty($values['Name']) ? $values['Name'] : '';
$this->Count = !empty($values['FirstOpen']) ? $values['FirstOpen'] : 0;
$this->Count = !empty($values['Count']) ? $values['Count'] : 0;
}

public function getType(): string
Expand Down
2 changes: 1 addition & 1 deletion src/Postmark/Models/PostmarkClickList.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public function __construct(array $values)
{
$this->TotalCount = !empty($values['TotalCount']) ? $values['TotalCount'] : 0;
$tempClicks = [];
foreach ($values['Clicks'] as $click) {
foreach ($values['Clicks'] ?? [] as $click) {
$obj = json_decode(json_encode($click));
$postmarkClick = new PostmarkClick((array) $obj);

Expand Down
2 changes: 1 addition & 1 deletion src/Postmark/Models/PostmarkDeliveryStats.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public function __construct(array $values)
{
$this->InactiveMails = !empty($values['InactiveMails']) ? $values['InactiveMails'] : 0;
$tempBounces = [];
foreach ($values['Bounces'] as $bounce) {
foreach ($values['Bounces'] ?? [] as $bounce) {
$obj = json_decode(json_encode($bounce));
$postmarkBounce = new PostmarkBounceSummary((array) $obj);

Expand Down
2 changes: 1 addition & 1 deletion src/Postmark/Models/PostmarkDomainList.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public function __construct(array $values)
{
$this->TotalCount = !empty($values['TotalCount']) ? $values['TotalCount'] : 0;
$tempDomains = [];
foreach ($values['Domains'] as $domain) {
foreach ($values['Domains'] ?? [] as $domain) {
$obj = json_decode(json_encode($domain));
$postmarkDomain = new PostmarkDomain((array) $obj);

Expand Down
3 changes: 2 additions & 1 deletion src/Postmark/Models/PostmarkInboundMessageList.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public function __construct(array $values)
{
$this->TotalCount = !empty($values['TotalCount']) ? $values['TotalCount'] : 0;
$tempInboundMessages = [];
foreach ($values['InboundMessages'] as $message) {
$inboundMessages = $values['InboundMessages'] ?? [];
foreach ($inboundMessages as $message) {
$obj = json_decode(json_encode($message));
$postmarkMessage = new PostmarkInboundMessage((array) $obj);

Expand Down
2 changes: 1 addition & 1 deletion src/Postmark/Models/PostmarkInboundRuleTriggerList.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public function __construct(array $values)
{
$this->TotalCount = !empty($values['TotalCount']) ? $values['TotalCount'] : 0;
$tempRules = [];
foreach ($values['InboundRules'] as $rule) {
foreach ($values['InboundRules'] ?? [] as $rule) {
$obj = json_decode(json_encode($rule));
$postmarkServer = new PostmarkInboundRuleTrigger((array) $obj);

Expand Down
2 changes: 1 addition & 1 deletion src/Postmark/Models/PostmarkOpenList.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public function __construct(array $values)
{
$this->TotalCount = !empty($values['TotalCount']) ? $values['TotalCount'] : 0;
$tempOpens = [];
foreach ($values['Opens'] as $open) {
foreach ($values['Opens'] ?? [] as $open) {
$obj = json_decode(json_encode($open));
$postmarkOpen = new PostmarkOpen((array) $obj);

Expand Down
3 changes: 2 additions & 1 deletion src/Postmark/Models/PostmarkOutboundMessageList.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public function __construct(array $values)
{
$this->TotalCount = !empty($values['TotalCount']) ? $values['TotalCount'] : 0;
$tempMessages = [];
foreach ($values['Messages'] as $message) {
$messages = $values['Messages'] ?? [];
foreach ($messages as $message) {
$obj = json_decode(json_encode($message));
$postmarkMessage = new PostmarkOutboundMessage((array) $obj);

Expand Down
2 changes: 1 addition & 1 deletion src/Postmark/Models/PostmarkSenderSignatureList.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public function __construct(array $values)
{
$this->TotalCount = !empty($values['TotalCount']) ? $values['TotalCount'] : 0;
$tempSigs = [];
foreach ($values['SenderSignatures'] as $open) {
foreach ($values['SenderSignatures'] ?? [] as $open) {
$obj = json_decode(json_encode($open));
$postmarkSenderSig = new PostmarkSenderSignature((array) $obj);

Expand Down
2 changes: 1 addition & 1 deletion src/Postmark/Models/PostmarkServerList.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public function __construct(array $values)
{
$this->TotalCount = !empty($values['TotalCount']) ? $values['TotalCount'] : 0;
$tempServers = [];
foreach ($values['Servers'] as $server) {
foreach ($values['Servers'] ?? [] as $server) {
$obj = json_decode(json_encode($server));
$postmarkServer = new PostmarkServer((array) $obj);

Expand Down
2 changes: 1 addition & 1 deletion src/Postmark/Models/PostmarkTemplateList.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public function __construct(array $values)
{
$this->TotalCount = !empty($values['TotalCount']) ? $values['TotalCount'] : 0;
$tempTemplates = [];
foreach ($values['Templates'] as $template) {
foreach ($values['Templates'] ?? [] as $template) {
$obj = json_decode(json_encode($template));
$postmarkTemplate = new PostmarkTemplate((array) $obj);

Expand Down
Loading