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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- `PostmarkClientBase::sdkVersion()` reports the installed SDK version, resolved at runtime from
Composer's package metadata so it cannot drift from the tag a customer actually has. Falls back
to `PostmarkClientBase::SDK_VERSION_FALLBACK` when that metadata is unavailable — a vendored
copy, a Phar, or a php-scoper'd build — rather than throwing.
- `X-Client-Type`, `X-Client-Version` and `X-Client-Language` request headers, so API traffic can
be attributed to an SDK and version without scraping the User-Agent.
- `composer-runtime-api: ^2.0` is now a declared dependency, since the version lookup uses it.

### Changed
- `User-Agent` is now `Postmark-PHP/<version> (PHP/<x.y.z>; OS/<family>)`, matching the
`product/version (comment)` grammar in RFC 9110 §10.1.5. The `Postmark-PHP` product token is
unchanged, so any server-side reporting keyed on it keeps working; the `/<version>` suffix and
the restructured comment are new.

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

### Added
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"description": "The officially supported client for Postmark (https://postmarkapp.com)",
"require": {
"php": "~8.1 || ~8.2|| ~8.3 || ~8.4",
"composer-runtime-api": "^2.0",
"guzzlehttp/guzzle": "^7.8"
},
"require-dev": {
Expand Down
76 changes: 75 additions & 1 deletion src/Postmark/PostmarkClientBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

namespace Postmark;

use Composer\InstalledVersions;
use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
use Postmark\Models\PostmarkException;
Expand All @@ -18,6 +19,74 @@
*/
abstract class PostmarkClientBase
{
/**
* Version reported when Composer's runtime metadata is unavailable, such as a
* source checkout with no installed package.
*
* Kept in step with the newest CHANGELOG entry by
* PostmarkClientBaseTest::testFallbackVersionMatchesChangelog.
*
* @var string
*/
public const SDK_VERSION_FALLBACK = '7.0.0';

/** The Packagist name this package is installed under. */
private const PACKAGE_NAME = 'wildbit/postmark-php';

/** Memoized result of {@see self::sdkVersion()}. */
private static ?string $sdkVersion = null;

/**
* The installed version of this SDK, as reported to the API.
*
* Resolution must never throw: this runs on every request, and a failure here
* would surface as a non-PostmarkException fatal rather than an API error.
* getPrettyVersion() throws OutOfBoundsException when the package is absent
* from the installed map — which is the normal case for a vendored copy, a
* Phar, a php-scoper'd build, or after a Packagist rename — and class_exists()
* does not guard that, because in any Composer-managed host project the class
* exists and simply does not know about us. isInstalled() is the documented
* non-throwing probe; the catch is belt-and-braces.
*/
public static function sdkVersion(): string
{
if (null !== self::$sdkVersion) {
return self::$sdkVersion;
}

$version = null;

if (class_exists(InstalledVersions::class)) {
try {
if (InstalledVersions::isInstalled(self::PACKAGE_NAME)) {
$version = InstalledVersions::getPrettyVersion(self::PACKAGE_NAME);
}
} catch (\Throwable $e) {
$version = null;
}
}

return self::$sdkVersion = self::normalizeVersion($version ?? self::SDK_VERSION_FALLBACK);
}

/**
* Coerce a Composer version into a valid RFC 9110 product-version token.
*
* Tags are v-prefixed, so getPrettyVersion() yields "v7.0.0" while the fallback
* is "7.0.0"; without stripping, the header format would differ by install
* shape. Branch installs yield "dev-feature/x", and "/" is a delimiter rather
* than a token character (RFC 9110 §5.6.2), which would mis-split the
* User-Agent for any strict parser.
*/
private static function normalizeVersion(string $version): string
{
$normalized = preg_replace('/[^A-Za-z0-9._+-]/', '-', ltrim($version, 'vV'));

return ('' === $normalized || null === $normalized)
? self::SDK_VERSION_FALLBACK
: $normalized;
}

/**
* BASE_URL is "https://api.postmarkapp.com".
*
Expand Down Expand Up @@ -112,7 +181,12 @@ protected function processRestRequest($method = null, $path = null, array $body
$options = [
RequestOptions::HTTP_ERRORS => false,
RequestOptions::HEADERS => [
'User-Agent' => "Postmark-PHP (PHP Version:{$this->version}, OS:{$this->os})",
// Product token stays "Postmark-PHP" — it predates this change and any
// server-side reporting keyed on it would break silently otherwise.
'User-Agent' => 'Postmark-PHP/' . self::sdkVersion() . " (PHP/{$this->version}; OS/{$this->os})",
'X-Client-Type' => 'SDK',
'X-Client-Version' => self::sdkVersion(),
'X-Client-Language' => 'php',
'Accept' => 'application/json',
'Content-Type' => 'application/json',
$this->authorization_header => $this->authorization_token,
Expand Down
8 changes: 7 additions & 1 deletion tests/PostmarkAdminClientDomainTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,15 @@ public function testClientCanDeleteDomain()

$domains = $client->listDomains()->getDomains();

// Verify the deleted domain is not in the list
$deletedDomainFound = false;
foreach ($domains as $key => $value) {
$this->assertNotSame($domain->getName(), $value->getName());
if ($value->getID() === $domain->getID()) {
$deletedDomainFound = true;
break;
}
}
$this->assertFalse($deletedDomainFound, 'Deleted domain should not be found in the list');
}

public function testClientCanVerifyDKIM()
Expand Down
36 changes: 29 additions & 7 deletions tests/PostmarkAdminClientSenderSignatureTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
require_once __DIR__ . '/PostmarkClientBaseTest.php';

use Postmark\PostmarkAdminClient;
use Exception;

/**
* @internal
Expand Down Expand Up @@ -43,7 +44,13 @@ public function testClientCanGetSingleSignature()
$tk = parent::$testKeys;

$client = new PostmarkAdminClient($tk->WRITE_ACCOUNT_TOKEN, $tk->TEST_TIMEOUT);
$id = $client->listSenderSignatures()->getSenderSignatures()[0]->getID();
$signatures = $client->listSenderSignatures()->getSenderSignatures();

if (empty($signatures)) {
$this->markTestSkipped('No sender signatures available in test account');
}

$id = $signatures[0]->getID();
$sig = $client->getSenderSignature($id);

$this->assertNotEmpty($sig->getName());
Expand All @@ -55,7 +62,7 @@ public function testClientCanCreateSignature()
$client = new PostmarkAdminClient($tk->WRITE_ACCOUNT_TOKEN, $tk->TEST_TIMEOUT);

$i = $tk->WRITE_TEST_SENDER_SIGNATURE_PROTOTYPE;
$sender = str_replace('[TOKEN]', 'test-php-create' . date('U'), $i);
$sender = str_ireplace('[TOKEN]', 'test-php-create' . date('U'), $i);
$name = 'test-php-create-' . date('U');
$note = 'This is a test note';

Expand All @@ -75,7 +82,7 @@ public function testClientCanEditSignature()
$name = 'test-php-edit-' . date('U');

$i = $tk->WRITE_TEST_SENDER_SIGNATURE_PROTOTYPE;
$sender = str_replace('[TOKEN]', 'test-php-edit' . date('U'), $i);
$sender = str_ireplace('[TOKEN]', 'test-php-edit' . date('U'), $i);

$exploded = explode('@', $tk->WRITE_TEST_SENDER_SIGNATURE_PROTOTYPE);
$returnPath = 'test.' . $exploded[1];
Expand All @@ -99,18 +106,33 @@ public function testClientCanDeleteSignature()
$client = new PostmarkAdminClient($tk->WRITE_ACCOUNT_TOKEN, $tk->TEST_TIMEOUT);

$i = $tk->WRITE_TEST_SENDER_SIGNATURE_PROTOTYPE;
$sender = str_replace('[TOKEN]', 'test-php-delete' . date('U'), $i);
$timestamp = date('U') . '-' . uniqid();
// Create a unique email by replacing the [TOKEN] placeholder
$sender = str_ireplace('[TOKEN]', 'test-php-delete-' . $timestamp, $i);

$name = 'test-php-delete-' . date('U');
// Validate the generated email is valid
if (!filter_var($sender, FILTER_VALIDATE_EMAIL)) {
$this->fail("Generated email address is invalid: $sender");
}

$name = 'test-php-delete-' . $timestamp;

// Now try to create the signature
$sig = $client->createSenderSignature($sender, $name);

$client->deleteSenderSignature($sig->getID());

$sigs = $client->listSenderSignatures()->getSenderSignatures();

// Verify the deleted signature is not in the list
$deletedSignatureFound = false;
foreach ($sigs as $key => $value) {
$this->assertNotSame($sig->getName(), $value->getName());
if ($value->getID() === $sig->getID()) {
$deletedSignatureFound = true;
break;
}
}
$this->assertFalse($deletedSignatureFound, 'Deleted signature should not be found in the list');
}

public function testClientCanRequestNewVerificationForSignature()
Expand All @@ -119,7 +141,7 @@ public function testClientCanRequestNewVerificationForSignature()
$client = new PostmarkAdminClient($tk->WRITE_ACCOUNT_TOKEN, $tk->TEST_TIMEOUT);

$i = $tk->WRITE_TEST_SENDER_SIGNATURE_PROTOTYPE;
$sender = str_replace('[TOKEN]', 'test-php-reverify' . date('U'), $i);
$sender = str_ireplace('[TOKEN]', 'test-php-reverify' . date('U'), $i);

$name = 'test-php-reverify-' . date('U');
$sig = $client->createSenderSignature($sender, $name);
Expand Down
88 changes: 83 additions & 5 deletions tests/PostmarkClientEmailTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Postmark\Models\PostmarkException;
use Postmark\Models\PostmarkMessage;
use Postmark\PostmarkClient;
use Postmark\PostmarkClientBase;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\UriInterface;

Expand All @@ -31,9 +32,12 @@ public function testClientCanSendBasicMessage()

$currentTime = date('c');

// Generate a unique recipient email to avoid suppression issues
$uniqueRecipient = 'test-' . uniqid() . '@blackhole.postmarkapp.com';

$response = $client->sendEmail(
$tk->WRITE_TEST_SENDER_EMAIL_ADDRESS,
$tk->WRITE_TEST_EMAIL_RECIPIENT_ADDRESS,
$uniqueRecipient,
"Hello from the PHP Postmark Client Tests! ({$currentTime})",
'<b>Hi there!</b>',
'This is a text body for a test email.'
Expand All @@ -49,10 +53,13 @@ public function testClientCanSetMessageStream()

$currentTime = date('c');

// Generate a unique recipient email to avoid suppression issues
$uniqueRecipient = 'test-' . uniqid() . '@blackhole.postmarkapp.com';

// Sending with a valid stream
$response = $client->sendEmail(
$tk->WRITE_TEST_SENDER_EMAIL_ADDRESS,
$tk->WRITE_TEST_EMAIL_RECIPIENT_ADDRESS,
$uniqueRecipient,
"Hello from the PHP Postmark Client Tests! ({$currentTime})",
'<b>Hi there!</b>',
'This is a text body for a test email via the default stream.',
Expand Down Expand Up @@ -102,9 +109,12 @@ public function testClientSendModel()

$currentTime = date('c');

// Generate a unique recipient email to avoid suppression issues
$uniqueRecipient = 'test-' . uniqid() . '@blackhole.postmarkapp.com';

$emailModel = new PostmarkMessage();
$emailModel->setFrom($tk->WRITE_TEST_SENDER_EMAIL_ADDRESS);
$emailModel->setTo($tk->WRITE_TEST_EMAIL_RECIPIENT_ADDRESS);
$emailModel->setTo($uniqueRecipient);
$emailModel->setSubject("Hello from the PHP Postmark Client Tests! ({$currentTime})");
$emailModel->setHtmlBody('<b>Hi there! sent via a model.</b>');
$emailModel->setTextBody('This is a text body for a test email sent via a model.');
Expand All @@ -130,9 +140,12 @@ public function testClientCanSendMessageWithRawAttachment()
'text/plain'
);

// Generate a unique recipient email to avoid suppression issues
$uniqueRecipient = 'test-' . uniqid() . '@blackhole.postmarkapp.com';

$response = $client->sendEmail(
$tk->WRITE_TEST_SENDER_EMAIL_ADDRESS,
$tk->WRITE_TEST_EMAIL_RECIPIENT_ADDRESS,
$uniqueRecipient,
"Hello from the PHP Postmark Client Tests! ({$currentTime})",
'<b>Hi there!</b>',
'This is a text body for a test email.',
Expand Down Expand Up @@ -162,9 +175,12 @@ public function testClientCanSendMessageWithFileSystemAttachment()
'image/png'
);

// Generate a unique recipient email to avoid suppression issues
$uniqueRecipient = 'test-' . uniqid() . '@blackhole.postmarkapp.com';

$response = $client->sendEmail(
$tk->WRITE_TEST_SENDER_EMAIL_ADDRESS,
$tk->WRITE_TEST_EMAIL_RECIPIENT_ADDRESS,
$uniqueRecipient,
"Hello from the PHP Postmark Client Tests! ({$currentTime})",
'<b>Hi there! From <img src="cid:hello.png"/></b>',
'This is a text body for a test email.',
Expand Down Expand Up @@ -262,4 +278,66 @@ public function testRequestSentWithCustomGuzzleClientHasCorrectUri()
sprintf('%s://%s', $lastRequestUri->getScheme(), $lastRequestUri->getHost())
);
}

public function testClientSetsCorrectHeaders()
{
$successResponse = new Response(
200,
['Content-Type' => 'application/json'],
json_encode([
'To' => 'recipient@example.com',
'SubmittedAt' => '2023-01-01T00:00:00Z',
'MessageId' => '0a129aee-e1cd-480d-b08d-4f48548ff48d',
'ErrorCode' => 0,
'Message' => 'OK',
])
);

$guzzleMockHandler = new MockHandler();
$guzzleMockHandler->append($successResponse);

$httpHistoryContainer = [];

$handlerStack = HandlerStack::create($guzzleMockHandler);
$handlerStack->push(Middleware::history($httpHistoryContainer), 'history');

$guzzleClient = new Client([
'handler' => $handlerStack,
]);
$postmarkClient = new PostmarkClient('test-token');

$postmarkClient->setClient($guzzleClient);

$postmarkClient->sendEmail(
'sender@example.com',
'recipient@example.com',
'Test message',
null,
'Text body'
);

// @var RequestInterface $lastRequest
$lastRequest = $httpHistoryContainer[0]['request'];

// Verify the new headers are present
$this->assertEquals('SDK', $lastRequest->getHeaderLine('X-Client-Type'));
$this->assertEquals('php', $lastRequest->getHeaderLine('X-Client-Language'));

// Derived from Composer directly rather than from sdkVersion(), so that a
// wrong version fails here instead of the assertion agreeing with itself.
$expectedVersion = ltrim(
\Composer\InstalledVersions::getPrettyVersion('wildbit/postmark-php') ?? '',
'vV'
);
$this->assertNotSame('', $expectedVersion);
$this->assertEquals($expectedVersion, $lastRequest->getHeaderLine('X-Client-Version'));

// Verify User-Agent shape in full: product/version (comment), per RFC 9110.
$userAgent = $lastRequest->getHeaderLine('User-Agent');
$this->assertMatchesRegularExpression(
'#^Postmark-PHP/[A-Za-z0-9._+-]+ \(PHP/\S+; OS/\S+\)$#',
$userAgent
);
$this->assertStringContainsString('Postmark-PHP/' . $expectedVersion . ' ', $userAgent);
}
}
Loading