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
76 changes: 60 additions & 16 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,82 @@ 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
# Fork PRs legitimately have no credentials — CircleCI withholds project env vars from them by
# design — so skipping is correct there and the job must stay green. On the TRUNK build a missing
# token is a configuration fault, and a job that exits 0 having run nothing is the same
# silent-green failure this PR exists to remove: php81 sat red for ten months precisely because
# nobody could tell "no credentials" from "79 regressions".
- run:
name: Require credentials on trunk (fork PRs skip by design)
command: |
if [ -z "${CIRCLE_PR_NUMBER:-}" ] && [ -z "${WRITE_ACCOUNT_TOKEN:-}" ]; then
echo "WRITE_ACCOUNT_TOKEN is unset on a trunk build — the integration suite would skip"
echo "every test and report success. Failing instead."
exit 1
fi
- 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 +114,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
74 changes: 74 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,80 @@ 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)

### Upgrading from v7 — read this first

Three things change behaviour without you changing a line of code. Everything else in this release is
either a declared signature break (see **Changed**) or a straight bug fix.

| What | You will notice |
|---|---|
| `getDeliveryStatistics()` bounce counts | Every bounce category reported `Count = 0` in every released v7. It now reports real numbers. **Any alert or dashboard calibrated against that zero will start firing.** |
| Guzzle 8 transport exceptions | `catch (ConnectException $e)` around a send stops matching a plain timeout. Nothing errors — the catch just stops running. Catch `GuzzleException`, or add the new types. |
| `PostmarkAttachment::fromFile()` | Previously sent an attachment with empty content when the file could not be read; now throws `RuntimeException`. A latent path bug becomes a hard failure on upgrade. |

The Guzzle floor also rises, which can block `composer update` — see **Changed**.

### 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.
- **BREAKING (behaviour, not signature) — Guzzle 8 reclassified transport exceptions, so
`catch (ConnectException $e)` around a send will silently stop matching a timeout.** This is the
largest blast radius in v8.0.0 and the reason it is filed here rather than under Added: nothing in
your code changes, and a swallowed timeout is worse than a fatal because nothing tells you.

Composer resolves the highest satisfying version, so upgrading puts you on Guzzle 8 unless you pin
otherwise — this is not opt-in. 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:

| 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.
If you catch the transport family, catch `GuzzleException` or add the new types.
- **The Guzzle floor rises from `^7.8` to `^7.15.2 || ^8.0.1`.** This can block `composer update` for
a consumer whose other dependencies pin an older Guzzle 7.x. 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).

### Added
- PHP 8.5 to the CI matrix.
- **Guzzle 8 support** alongside Guzzle 7, thanks to [@simPod](https://github.com/simPod) (#165).
Both majors are exercised in CI rather than assumed compatible — see the exception
reclassification under **Changed**, which is the part that can break your code.
### 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 `''`). Only `TypeCode` threw `TypeError`. **`Type` silently coerced `0` to the
string `"0"`** — no `src/` file declares `strict_types`, so a bounce with no `Type` in the response
reported a type of `"0"` rather than failing. Silent corruption on a deliverability field, not a
crash, which is the stronger reason to take this release.
- 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
31 changes: 27 additions & 4 deletions src/Postmark/Models/PostmarkAttachment.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,42 @@ 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.
//
// The @ is kept so a consumer's error handler does not turn a warning into an exception before
// the RuntimeException below can be thrown — but the suppressed reason is recovered and
// included, because "missing" / "permission denied" / "failed stream wrapper" are a one-minute
// fix and a support ticket respectively.
$contents = @file_get_contents($filePath);

if (false === $contents) {
$reason = error_get_last()['message'] ?? null;

throw new \RuntimeException(sprintf(
'Unable to read attachment file "%s".%s',
$filePath,
null === $reason ? '' : ' ' . $reason
));
}

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
Loading