From c2e1d3d6cfd2ba488bb2f79b68ff0ce486d2507c Mon Sep 17 00:00:00 2001 From: Eli Wood Date: Thu, 6 Aug 2026 05:41:37 -0400 Subject: [PATCH] Stop fatalling on valid API responses (v7.0.1) Two production reports, one defect class. Six getters promised a non-nullable return while their constructor explicitly assigns null when the API omits the field, so they were guaranteed to throw TypeError on a perfectly valid response. Reported against PostmarkOpen::getGeo() for a broadcast-stream message with no geo data; a scan of src/Postmark found five more of the same shape. PostmarkClick already declared its equivalents loosely, which is why clicks worked and opens did not -- the inconsistency is the tell that this was an oversight rather than a design. Separately, getBounces()'s $messageID filter became ?int in c7a4371 and shipped from v5.0.1 onward. Postmark MessageIDs are GUIDs, so the filter has been unusable for two years: passing one throws "Argument #7 ($messageID) must be of type ?int, string given". Restored to ?string, matching the pre-v5.0.1 documented type. Shipped as a patch rather than held for the v8 branch. Widening a return type to nullable is covariant, so a subclass overriding one of these getters with the narrower type stays compatible -- no class-load break, and callers were receiving a fatal rather than a value. The one contravariant case is getBounces(), where a subclass override declaring ?int must change; that is noted in the CHANGELOG, and no caller can currently be passing a usable value anyway. Customers hitting a fatal should not have to take a major upgrade (which also drops PHP 8.1) to get the fix. NullableGetterRegressionTest covers all seven and needs no credentials. Verified it fails on unfixed main: 2 errors, 3 failures. Also fixed .php-cs-fixer.dist.php, which built a Finder and never called setFinder(), so the fixer aborted with "You must call one of in() or append() methods" and had never run at all. Added a credential-free static-analysis job to CI -- PHPStan gates, php-cs-fixer is advisory only because 8 files are already non-conforming on main and a check that ships red just teaches people to ignore it. --- .circleci/config.yml | 25 +++++ .php-cs-fixer.cache | 1 + .php-cs-fixer.dist.php | 9 +- CHANGELOG.md | 31 ++++++ src/Postmark/Models/PostmarkMessageBase.php | 4 +- src/Postmark/Models/PostmarkOpen.php | 6 +- .../Models/Webhooks/WebhookConfiguration.php | 2 +- src/Postmark/PostmarkClient.php | 4 +- tests/NullableGetterRegressionTest.php | 99 +++++++++++++++++++ 9 files changed, 171 insertions(+), 10 deletions(-) create mode 100644 .php-cs-fixer.cache create mode 100644 tests/NullableGetterRegressionTest.php diff --git a/.circleci/config.yml b/.circleci/config.yml index 847e337b..97f7026d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -5,6 +5,11 @@ 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: + name: static - unit-tests: name: php81 version: "8.1" @@ -25,6 +30,26 @@ workflows: - php83 jobs: + static-analysis: + docker: + - image: cimg/php:8.1 + steps: + - checkout + - run: + name: Install dependencies + command: composer install --no-interaction + - run: + name: PHPStan + command: vendor/bin/phpstan analyse --memory-limit=1G --no-progress + # php-cs-fixer is deliberately NOT a gate yet: its config was missing + # setFinder(), so the fixer has never run here and 8 files are already + # non-conforming on main. A check that ships red just teaches people to + # ignore it. Run it for visibility, promote it to a gate after a + # formatting pass. + - run: + name: php-cs-fixer (advisory) + command: vendor/bin/php-cs-fixer fix --dry-run --diff || true + unit-tests: parameters: version: diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache new file mode 100644 index 00000000..f398ca38 --- /dev/null +++ b/.php-cs-fixer.cache @@ -0,0 +1 @@ +{"php":"8.4.4","version":"3.82.2:v3.82.2#684ed3ab41008a2a4848de8bde17eb168c596247","indent":" ","lineEnding":"\n","rules":{"blank_line_before_statement":{"statements":["break","case","continue","declare","default","exit","goto","include","include_once","phpdoc","require","require_once","return","switch","throw","try","yield","yield_from"]},"combine_consecutive_issets":true,"combine_consecutive_unsets":true,"empty_loop_body":true,"explicit_indirect_variable":true,"explicit_string_variable":true,"fully_qualified_strict_types":{"import_symbols":true},"heredoc_to_nowdoc":true,"method_argument_space":{"after_heredoc":true},"method_chaining_indentation":true,"multiline_comment_opening_closing":true,"multiline_whitespace_before_semicolons":{"strategy":"new_line_for_chained_calls"},"no_extra_blank_lines":{"tokens":["attribute","break","case","continue","curly_brace_block","default","extra","parenthesis_brace_block","return","square_brace_block","switch","throw","use"]},"no_superfluous_elseif":true,"no_superfluous_phpdoc_tags":{"allow_mixed":true,"remove_inheritdoc":true},"no_unneeded_control_parentheses":{"statements":["break","clone","continue","echo_print","negative_instanceof","others","return","switch_case","yield","yield_from"]},"no_useless_else":true,"no_useless_return":true,"no_whitespace_before_comma_in_array":{"after_heredoc":true},"operator_linebreak":true,"ordered_class_elements":true,"ordered_types":true,"php_unit_data_provider_method_order":true,"php_unit_internal_class":true,"php_unit_test_class_requires_covers":true,"phpdoc_add_missing_param_annotation":true,"phpdoc_no_empty_return":true,"phpdoc_order_by_value":true,"phpdoc_types_order":true,"phpdoc_var_annotation_correct_order":true,"protected_to_private":true,"return_assignment":true,"self_static_accessor":true,"single_line_comment_style":true,"single_line_empty_body":true,"string_implicit_backslashes":true,"trailing_comma_in_multiline":{"after_heredoc":true},"whitespace_after_comma_in_array":{"ensure_single_space":true},"array_indentation":true,"array_syntax":true,"cast_spaces":true,"concat_space":{"spacing":"one"},"function_declaration":true,"new_with_parentheses":{"anonymous_class":false},"single_space_around_construct":true,"binary_operator_spaces":true,"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_anonymous_functions":true,"allow_single_line_empty_anonymous_classes":true},"class_definition":{"single_line":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"no_blank_lines_after_class_opening":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_imports":{"imports_order":["class","function","const"],"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":true,"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":true,"visibility_required":true,"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":true,"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":{"stick_comment_to_next_continuous_control_statement":true},"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"align_multiline_comment":true,"backtick_to_shell_exec":true,"class_attributes_separation":{"elements":{"method":"one"}},"class_reference_name_casing":true,"clean_namespace":true,"declare_parentheses":true,"echo_tag_syntax":true,"empty_loop_condition":true,"general_phpdoc_tag_rename":{"replacements":{"inheritDocs":"inheritDoc"}},"global_namespace_import":{"import_classes":true,"import_constants":false,"import_functions":false},"include":true,"increment_style":true,"integer_literal_case":true,"lambda_not_used_import":true,"linebreak_after_opening_tag":true,"magic_constant_casing":true,"magic_method_casing":true,"native_function_casing":true,"native_type_declaration_casing":true,"no_alias_language_construct_call":true,"no_alternative_syntax":true,"no_binary_string":true,"no_blank_lines_after_phpdoc":true,"no_empty_comment":true,"no_empty_phpdoc":true,"no_empty_statement":true,"no_leading_namespace_whitespace":true,"no_mixed_echo_print":true,"no_multiline_whitespace_around_double_arrow":true,"no_null_property_initialization":true,"no_short_bool_cast":true,"no_singleline_whitespace_before_semicolons":true,"no_spaces_around_offset":true,"no_trailing_comma_in_singleline":true,"no_unneeded_braces":{"namespaces":true},"no_unneeded_import_alias":true,"no_unset_cast":true,"no_unused_imports":true,"no_useless_concat_operator":true,"no_useless_nullsafe_operator":true,"normalize_index_brace":true,"nullable_type_declaration":true,"nullable_type_declaration_for_default_null_value":true,"object_operator_without_whitespace":true,"php_unit_fqcn_annotation":true,"php_unit_method_casing":true,"phpdoc_align":true,"phpdoc_annotation_without_dot":true,"phpdoc_indent":true,"phpdoc_inline_tag_normalizer":true,"phpdoc_no_access":true,"phpdoc_no_alias_tag":true,"phpdoc_no_package":true,"phpdoc_no_useless_inheritdoc":true,"phpdoc_order":{"order":["param","return","throws"]},"phpdoc_return_self_reference":true,"phpdoc_scalar":true,"phpdoc_separation":{"groups":[["Annotation","NamedArgumentConstructor","Target"],["author","copyright","license"],["category","package","subpackage"],["property","property-read","property-write"],["deprecated","link","see","since"]]},"phpdoc_single_line_var_spacing":true,"phpdoc_summary":true,"phpdoc_tag_type":{"tags":{"inheritDoc":"inline"}},"phpdoc_to_comment":true,"phpdoc_trim":true,"phpdoc_trim_consecutive_blank_line_separation":true,"phpdoc_types":true,"phpdoc_var_without_name":true,"semicolon_after_instruction":true,"simple_to_complex_string_variable":true,"single_line_comment_spacing":true,"single_quote":true,"space_after_semicolon":{"remove_in_empty_for_expressions":true},"standardize_increment":true,"standardize_not_equals":true,"switch_continue_to_break":true,"trim_array_spaces":true,"type_declaration_spaces":true,"types_spaces":true,"yoda_style":true,"octal_notation":true,"assign_null_coalescing_to_coalesce_equal":true,"heredoc_indentation":true,"list_syntax":true,"ternary_to_null_coalescing":true},"hashes":{"tests\/PostmarkClientEmailTest.php":"c6189d0e828bef819878206cae49dbcd","tests\/PostmarkClientWebhooksTest.php":"585fcc9be39f0412ec8298922f33b9d8","tests\/TestingKeys.php":"f1aa3b802551905390490b5ce09ec34b","tests\/PostmarkAdminClientSenderSignatureTest.php":"c2d40ed8bad3b14c5e6398cf2edc10d9","tests\/PostmarkClickClientStatisticsTest.php":"ccb6cfc8173c4b786030d50bba0c58a2","tests\/PostmarkClientOutboundMessageTest.php":"34e138738bc9892d17b208e20e9a4e83","tests\/PostmarkClientBounceTest.php":"bf0060f3b5411deaf651173ef8387074","tests\/PostmarkClientMessageStreamsTest.php":"48ec12b95adb57931f0c35278426dba8","tests\/PostmarkClientBaseTest.php":"6149e7f01e42a5445eb5c3e83a062e50","tests\/PostmarkAdminClientDataRemovalTest.php":"13205d7a55903ab42ce2ac86a9ccd938","tests\/PostmarkAdminClientDomainTest.php":"845e28e63b4dcb8c744e405945d381cf","tests\/PostmarkClientTemplatesTest.php":"b824a3904df9ccc64edc5029a1e2d00a","tests\/PostmarkClientSuppressionsTest.php":"015d38b7cccd8120d8b099ae722baa69","tests\/PostmarkClientRuleTriggerTest.php":"ee9e25d7a6774ba4e5f38354f9e8b9cc","tests\/PostmarkClientEmailsAsStringOrArrayTest.php":"0a2b5dd12fef42de883817e377351a24","tests\/PostmarkClientInboundMessageTest.php":"0c263bb4ae0981952ef1a0ce87cf07e2","tests\/PostmarkClientStatisticsTest.php":"675729a36aa26f01cdf6ff7da3c07510","tests\/PostmarkAdminClientServersTest.php":"11141b71f1e95f2baca3dbc651c77353","tests\/NullableGetterRegressionTest.php":"2cf50c9303ac009d7a0f572035c4ab29","tests\/PostmarkClientServerTest.php":"ebf5e3733d395f2b20342d4d2684e9bf","src\/Postmark\/Models\/TemplatedPostmarkMessage.php":"e455a3be856931632026d15cf5bc9cc3","src\/Postmark\/Models\/PostmarkServerList.php":"21cd63c25fa6e4f99a944e0becec3a96","src\/Postmark\/Models\/PostmarkMessageEventDetails.php":"34d8bdd7ae8183c14435128e21b87981","src\/Postmark\/Models\/PostmarkBounceSummary.php":"90c82841ae3a370f49893ed60c412ca6","src\/Postmark\/Models\/PostmarkSenderSignatureList.php":"5c1578eeb8bb84412cc87ff99c687166","src\/Postmark\/Models\/PostmarkSenderSignature.php":"ac5b31d38d030070d93fb9a6375e35cf","src\/Postmark\/Models\/PostmarkOutboundMessageList.php":"abb423d0ef1c5baf77d81df4f4c81482","src\/Postmark\/Models\/PostmarkOpenList.php":"fb6281c45b8e805f58a121136af504e0","src\/Postmark\/Models\/PostmarkMessageEvents.php":"d27d171db159c8bf3faa868433c75869","src\/Postmark\/Models\/PostmarkBounce.php":"d73075c5c9919a4944cd52e22ce70754","src\/Postmark\/Models\/Suppressions\/SuppressionChangeRequest.php":"8e8271b70e590166a8dbb498fc59b3c2","src\/Postmark\/Models\/Suppressions\/PostmarkSuppression.php":"4d1464f50a9898e99ce928dfe258c60e","src\/Postmark\/Models\/Suppressions\/PostmarkSuppressionList.php":"90a83e66d868b0d43a69568a3e2db69d","src\/Postmark\/Models\/Suppressions\/PostmarkSuppressionResultList.php":"45fad5831e774a8984caf93ddfab3036","src\/Postmark\/Models\/Suppressions\/PostmarkSuppressionRequestResult.php":"e4039e2888ecf651561cf4bac43f1386","src\/Postmark\/Models\/TemplateValidationResponse.php":"a05045943ae4983806498f1f9bccea3a","src\/Postmark\/Models\/PostmarkInboundMessageList.php":"66a07b58d6937fe4bc11c6d1930f3b97","src\/Postmark\/Models\/PostmarkTemplate.php":"b51cbf08e371d28175a1a5acf3cedb40","src\/Postmark\/Models\/PostmarkOutboundMessageDetail.php":"e665fef48b1390243bd2f5aadb72240e","src\/Postmark\/Models\/PostmarkServer.php":"c3f18e4b6e396d94746c0807777c6ef8","src\/Postmark\/Models\/PostmarkGeographyInfo.php":"49c01a2844600bb255f19bd5f8494268","src\/Postmark\/Models\/PostmarkException.php":"f21bfb5735097cbb1c3e876bde566144","src\/Postmark\/Models\/MessageStream\/PostmarkMessageStream.php":"cecc3949f34964fac38239f080e4b7e8","src\/Postmark\/Models\/MessageStream\/PostmarkMessageStreamArchivalConfirmation.php":"50306bc8a2c9d62c9c0be389b6caa953","src\/Postmark\/Models\/MessageStream\/PostmarkMessageStreamList.php":"891813edea671e2d412385ce24e52368","src\/Postmark\/Models\/PostmarkAddressFull.php":"56044b2f62aa46ea0f19f123086e6608","src\/Postmark\/Models\/PostmarkInboundMessage.php":"4257b6b11f5624cfaa2b186bc4d6be8e","src\/Postmark\/Models\/PostmarkMessageBase.php":"4adae71023b65df6848ca6bad240b7a2","src\/Postmark\/Models\/PostmarkDomain.php":"0f01599fb3e58bca08349f20f857cd95","src\/Postmark\/Models\/PostmarkOutboundMessage.php":"48e6a4becc306bf8813f196fc78492d0","src\/Postmark\/Models\/PostmarkMessage.php":"d7276a2851c6b323973096a0e9679406","src\/Postmark\/Models\/PostmarkBounceActivation.php":"7050e8d9b6073b707873ab92d2a084d1","src\/Postmark\/Models\/PostmarkInboundRuleTriggerList.php":"58c01717b4fa742534e218d8be9f0fcd","src\/Postmark\/Models\/PostmarkBounceList.php":"2395d35eabf6b330e2c6cb066abfea6e","src\/Postmark\/Models\/PostmarkDomainList.php":"f46a4bf5176cb750232a41faa163b811","src\/Postmark\/Models\/PostmarkClickList.php":"528061f329b80de653c437bdae9cf6b9","src\/Postmark\/Models\/DataRemovalRequestResponse.php":"e3f6610a0922446b5e01a839e9ffa63a","src\/Postmark\/Models\/PostmarkDeliveryStats.php":"e5d84610536116f895f40c653ec87a31","src\/Postmark\/Models\/PostmarkMessageEvent.php":"967037219abc864be94d89f3d3038de2","src\/Postmark\/Models\/PostmarkAgentInfo.php":"5f8957854fb21e93c2934688457e9b79","src\/Postmark\/Models\/PostmarkInboundRuleTrigger.php":"44227d72abf002f2ba23994fa998d412","src\/Postmark\/Models\/PostmarkDomainDetails.php":"2128512b05cb217d830c6226185f3a3b","src\/Postmark\/Models\/CaseInsensitiveArray.php":"3c8aa907f8b7e79e5ecf08432fc021a5","src\/Postmark\/Models\/PostmarkResponse.php":"dc3bcfad9a37dd7b97e7e7dcb5ef2796","src\/Postmark\/Models\/Webhooks\/HttpHeader.php":"6c9fc858979d0ca34fdfc39d9536456a","src\/Postmark\/Models\/Webhooks\/WebhookConfigurationClickTrigger.php":"b82384095172c86cc1072c43342503b0","src\/Postmark\/Models\/Webhooks\/WebhookConfigurationTriggers.php":"cb5350ada0d232ea36c020e41d0fff46","src\/Postmark\/Models\/Webhooks\/WebhookConfigurationListingResponse.php":"4733a3d475d5b52b2fc5c2743ecd6ce9","src\/Postmark\/Models\/Webhooks\/WebhookConfiguration.php":"5ab81444a62d64f1d85869cc477ea6cb","src\/Postmark\/Models\/Webhooks\/WebhookConfigurationSubscriptionChangeTrigger.php":"bbee62123b08297d28d2dd52ec60d204","src\/Postmark\/Models\/Webhooks\/WebhookConfigurationDeliveryTrigger.php":"1d875b40b6535cccf3ffc1b3030bdd8d","src\/Postmark\/Models\/Webhooks\/WebhookConfigurationBounceTrigger.php":"b763de47c7c320063b04118033b87e1e","src\/Postmark\/Models\/Webhooks\/HttpAuth.php":"d4c34c6e4ebae420bbf08535bf1cf137","src\/Postmark\/Models\/Webhooks\/WebhookConfigurationOpenTrigger.php":"5ff452fc311dc83394f265196ad743de","src\/Postmark\/Models\/Webhooks\/WebhookConfigurationSpamComplaintTrigger.php":"7c50a14e07222a3dec8a941b608c50db","src\/Postmark\/Models\/PostmarkOpen.php":"2266b1e18680094df52c9136b640ab5f","src\/Postmark\/Models\/PostmarkClick.php":"3b54e12ff2e4c98e669e159e6134a8fb","src\/Postmark\/Models\/PostmarkTemplateList.php":"df822b2ebc5fb948f231e33e306b4e49","src\/Postmark\/Models\/DynamicResponseModel.php":"34e0d013d12c010ffef05e014a6beb16","src\/Postmark\/Models\/PostmarkAttachment.php":"7d9c282ed6a89246541c08d6df437fd8","src\/Postmark\/Models\/PostmarkMessageDump.php":"3524696b5daea922b8f9b9f9b701fdeb","src\/Postmark\/Models\/Stats\/PostmarkOutboundLocationStats.php":"886735d7eac843e5b3d9975ab6ccfb14","src\/Postmark\/Models\/Stats\/PostmarkOutboundReadStats.php":"d505d00e413b2b93ad4caf0b67d8b15b","src\/Postmark\/Models\/Stats\/PostmarkOutboundSpamComplaintStats.php":"83732226e4a6d96bbd4a7f1153f8cda7","src\/Postmark\/Models\/Stats\/PostmarkOutboundClickStats.php":"3a6ef3e4859d94be8054f7e7a61a1d96","src\/Postmark\/Models\/Stats\/PostmarkOutboundOverviewStats.php":"1c4fb85dc878be47e0a249b181506569","src\/Postmark\/Models\/Stats\/PostmarkOutboundOpenStats.php":"12e00350058f2464d7c6924ac340b38f","src\/Postmark\/Models\/Stats\/PostmarkOutboundPlatformStats.php":"7614dcc71613177f418303155624f92a","src\/Postmark\/Models\/Stats\/PostmarkOutboundBounceStats.php":"e772ce2fab9b2ca066a316e4c5665041","src\/Postmark\/Models\/Stats\/PostmarkOutboundSentStats.php":"0c6737386f4de6652181c1443b646ecd","src\/Postmark\/Models\/Stats\/PostmarkOutboundTrackedStats.php":"09125390bc5f0e997aa4774c83e01854","src\/Postmark\/Models\/PostmarkBounceDump.php":"00b9dda4fc32c54596d924c1d1737041","src\/Postmark\/PostmarkClient.php":"54d297cd04796e96a1d6061288f0b772","src\/Postmark\/PostmarkAdminClient.php":"916c134f5da322b2637ea8266652ecc6","src\/Postmark\/PostmarkClientBase.php":"dc8362666c0622eedeed5ad2f34a9d37"}} \ No newline at end of file diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index be83d3bf..f3a91352 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -11,10 +11,15 @@ * @see https://activecampaign.atlassian.net/wiki/spaces/DEV/pages/24051783/ActiveCampaign+PHP+Coding+Style+Standards * @see https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/doc/ruleSets/index.rst */ -$finder = PhpCsFixer\Finder::create()->in(__DIR__); +$finder = PhpCsFixer\Finder::create() + ->in(__DIR__) + ->exclude(['vendor']); $config = new PhpCsFixer\Config(); -return $config->setRules([ +// setFinder() was missing, so `php-cs-fixer fix` with no path argument aborted with +// "You must call one of in() or append() methods before iterating over a Finder" — +// the fixer has never actually been runnable in this repo. +return $config->setFinder($finder)->setRules([ '@PhpCsFixer' => true, '@PHP82Migration' => true, 'concat_space' => ['spacing' => 'one'], // This is required by [PER coding style rule 6.2 binary operators](https://www.php-fig.org/per/coding-style/#62-binary-operators) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e3a2e82..3f073e3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ 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). +## [v7.0.1](https://github.com/ActiveCampaign/postmark-php/tree/v7.0.1) + +### Fixed +- **Getters backed by nullable properties no longer throw `TypeError`.** Each of these promised a + non-nullable return while its constructor explicitly assigns `null` when the API omits the + field, so they were guaranteed to fatal on a perfectly valid response: + `PostmarkOpen::getGeo()`, `PostmarkOpen::getClient()`, `PostmarkOpen::getOS()`, + `PostmarkMessageBase::getMetadata()`, `PostmarkMessageBase::getMessageStream()` and + `WebhookConfiguration::getHttpAuth()` now declare nullable return types. + Reported against `getGeo()` for a broadcast-stream message with no geo data. + (`PostmarkClick` already declared its equivalents loosely, which is why clicks worked and + opens did not.) +- **`PostmarkClient::getBounces()` accepts a MessageID again.** The `$messageID` filter was + changed to `?int` in `c7a4371` and released from v5.0.1 onward. Postmark MessageIDs are GUIDs, + so the filter has been unusable since — `getBounces(1, 0, null, null, null, null, $guid)` threw + `Argument #7 ($messageID) must be of type ?int, string given`. It is `?string` again, matching + the pre-v5.0.1 documented type. + +### Notes for upgraders +These are all bug fixes and none should require a change on your side. Widening a return type to +nullable is covariant, so a subclass that overrides one of the getters with the narrower type +stays compatible. The one thing to be aware of: a subclass that overrides `getBounces()` with +`?int $messageID` will need to change to `?string`, since parameter types are contravariant. If +you were catching the `TypeError` from any of the getters above as a workaround, you can drop it. + +### Internal +- `.php-cs-fixer.dist.php` never called `setFinder()`, so `php-cs-fixer` aborted with + "You must call one of in() or append() methods" and had never actually run. Fixed. +- CI gained a credential-free `static-analysis` job running PHPStan, which is the only check in + this repo a fork PR can currently exercise. + ## [v7.0.0](https://github.com/ActiveCampaign/postmark-php/tree/v7.0.0) ### Added diff --git a/src/Postmark/Models/PostmarkMessageBase.php b/src/Postmark/Models/PostmarkMessageBase.php index e7c0283d..5803f27f 100644 --- a/src/Postmark/Models/PostmarkMessageBase.php +++ b/src/Postmark/Models/PostmarkMessageBase.php @@ -171,7 +171,7 @@ public function setTrackLinks(string $TrackLinks): PostmarkMessageBase return $this; } - public function getMetadata(): array + public function getMetadata(): ?array { return $this->Metadata; } @@ -186,7 +186,7 @@ public function setMetadata(?array $Metadata = null): PostmarkMessageBase return $this; } - public function getMessageStream(): string + public function getMessageStream(): ?string { return $this->MessageStream; } diff --git a/src/Postmark/Models/PostmarkOpen.php b/src/Postmark/Models/PostmarkOpen.php index 53c7dc8f..3fe61e0a 100644 --- a/src/Postmark/Models/PostmarkOpen.php +++ b/src/Postmark/Models/PostmarkOpen.php @@ -63,7 +63,7 @@ public function setUserAgent(string $UserAgent): PostmarkOpen return $this; } - public function getGeo(): PostmarkGeographyInfo + public function getGeo(): ?PostmarkGeographyInfo { return $this->Geo; } @@ -114,7 +114,7 @@ public function setReceivedAt(string $ReceivedAt): PostmarkOpen return $this; } - public function getClient(): PostmarkAgentInfo + public function getClient(): ?PostmarkAgentInfo { return $this->Client; } @@ -129,7 +129,7 @@ public function setClient(mixed $Client): PostmarkOpen return $this; } - public function getOS(): PostmarkAgentInfo + public function getOS(): ?PostmarkAgentInfo { return $this->OS; } diff --git a/src/Postmark/Models/Webhooks/WebhookConfiguration.php b/src/Postmark/Models/Webhooks/WebhookConfiguration.php index a45d4222..4d25f976 100644 --- a/src/Postmark/Models/Webhooks/WebhookConfiguration.php +++ b/src/Postmark/Models/Webhooks/WebhookConfiguration.php @@ -116,7 +116,7 @@ public function setMessageStream(string $MessageStream): WebhookConfiguration return $this; } - public function getHttpAuth(): HttpAuth + public function getHttpAuth(): ?HttpAuth { return $this->HttpAuth; } diff --git a/src/Postmark/PostmarkClient.php b/src/Postmark/PostmarkClient.php index 912d68ff..e22996ed 100644 --- a/src/Postmark/PostmarkClient.php +++ b/src/Postmark/PostmarkClient.php @@ -306,7 +306,7 @@ public function getDeliveryStatistics(): PostmarkDeliveryStats * @param null|bool $inactive specifies if the bounce caused Postmark to deactivate this email * @param null|string $emailFilter Filter by email address * @param null|string $tag Filter by tag - * @param null|int $messageID Filter by MessageID + * @param null|string $messageID Filter by MessageID (a GUID, e.g. 454ef8fc-da5f-4662-b6d6-74a54d78504d) * @param null|string $fromdate filter for bounces after is date * @param null|string $todate filter for bounces before this date * @param null|string $messagestream Filter by Message Stream ID. If null, the default "outbound" transactional stream will be used. @@ -320,7 +320,7 @@ public function getBounces( ?bool $inactive = null, ?string $emailFilter = null, ?string $tag = null, - ?int $messageID = null, + ?string $messageID = null, ?string $fromdate = null, ?string $todate = null, ?string $messagestream = null diff --git a/tests/NullableGetterRegressionTest.php b/tests/NullableGetterRegressionTest.php new file mode 100644 index 00000000..e5537185 --- /dev/null +++ b/tests/NullableGetterRegressionTest.php @@ -0,0 +1,99 @@ + '454ef8fc-da5f-4662-b6d6-74a54d78504d', + 'ReceivedAt' => '2026-08-06T00:00:00Z', + ]); + + $this->assertNull($open->getGeo()); + $this->assertNull($open->getClient()); + $this->assertNull($open->getOS()); + } + + /** PostmarkClick already declared these loosely; Open must agree with it. */ + public function testOpenAndClickAgreeOnNullability(): void + { + foreach (['getGeo', 'getClient', 'getOS'] as $getter) { + $type = (new \ReflectionMethod(PostmarkOpen::class, $getter))->getReturnType(); + + $this->assertNotNull($type, "PostmarkOpen::{$getter}() should declare a return type."); + $this->assertTrue( + $type->allowsNull(), + "PostmarkOpen::{$getter}() must allow null; the constructor assigns null when the key is absent." + ); + } + } + + public function testMessageBaseGettersReturnNullBeforeTheyAreSet(): void + { + $message = new PostmarkMessage(); + + $this->assertNull($message->getMetadata()); + $this->assertNull($message->getMessageStream()); + } + + /** + * Build() declares `?HttpAuth $HttpAuth = null`, so a webhook configured + * without basic auth — the common case — returned null from a getter that + * promised HttpAuth. + */ + public function testWebhookConfigurationHttpAuthReturnsNullWhenUnset(): void + { + $configuration = new WebhookConfiguration( + 1, + 'https://example.com/hook', + 'outbound', + null, + [], + new WebhookConfigurationTriggers() + ); + + $this->assertNull($configuration->getHttpAuth()); + } + + /** + * MessageIDs are GUIDs. getBounces() declared ?int for this filter from + * c7a4371 (shipped v5.0.1 onward), which made it impossible to use. + */ + public function testGetBouncesAcceptsAGuidMessageId(): void + { + $parameter = null; + + foreach ((new \ReflectionMethod(\Postmark\PostmarkClient::class, 'getBounces'))->getParameters() as $candidate) { + if ('messageID' === $candidate->getName()) { + $parameter = $candidate; + + break; + } + } + + $this->assertNotNull($parameter, 'getBounces() must still accept a $messageID filter.'); + $this->assertSame('?string', (string) $parameter->getType()); + } +}