Skip to content
Merged
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
37 changes: 37 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Tests

on:
push:
pull_request:

permissions:
contents: read

jobs:
tests:
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
php: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
dependencies: [lowest, highest]
name: PHP ${{ matrix.php }} / ${{ matrix.dependencies }}
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: curl, json, simplexml, libxml
tools: composer:v2
coverage: none
- run: composer validate --strict
- name: Install dependencies
run: |
if [ '${{ matrix.dependencies }}' = 'lowest' ]; then
composer update --prefer-lowest --prefer-stable --prefer-dist --no-interaction
else
composer update --prefer-stable --prefer-dist --no-interaction
fi
- run: composer audit
- run: composer lint
- run: composer test
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Changelog

## 2.0.0 (unreleased)

### Breaking changes

- Require PHP 7.2.5+ (PHP 7 or 8) and Guzzle 7.15.2+. The previous dependency
range included Guzzle 6 and older PHP releases. Current Composer advisory
checks cannot resolve that legacy combination without exceptions.
- Require `overtrue/http` 1.2.3+, the first 1.x release containing both the
PHP 8 collection compatibility and resource-upload corrections used here.
- Endpoint paths beginning with a single slash now resolve under the configured
API base path, matching the documented `/mail/send` usage. Callers that need an
origin-root path must supply an absolute URL.

- Redirect following is disabled by default. Explicitly enabled redirects must
remain on the initial scheme, host, and port; cross-origin/downgrade redirects
throw before a second request is sent.

- Prebuilt multipart bodies and low-level HTTP-client multipart calls are rejected;
use the SDK `upload()` or `request()` multipart option instead.

### Fixed

- Use HTTPS by default and honor an explicitly configured base URI.
- Add POST authentication to URL-encoded form bodies or multipart fields.
- Preserve existing query parameters and form values, including duplicate and
encoded values; use correctly encoded authentication values.
- Remove the obsolete PSR-7 `modify_request()` dependency.
- Retain response casting, API-level failure responses, transport/HTTP exceptions,
and asynchronous behavior.

### Tests

- Add mocked outgoing-request regressions, syntax checks, dependency audits, and
lowest/latest dependency CI for PHP 7.2–7.4 and 8.0–8.5.
72 changes: 70 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ $apiKey = 'UWoBGa2sgxyxxxxxxxx';

$client = new SendCloud($apiUser, $apiKey);

$result = $client->post('/mail/send', [
$message = [
'from' => 'overtrue@domain.sendcloud.org',
'to' => 'demo@easywechat.com',
'subject' => '来自 SendCloud 的第一封邮件!',
'html' => '你太棒了!你已成功的 从 SendCloud 发送了一封测试邮件!',
]);
];

$result = $client->post('mail/send', $message);

var_dump($result);

Expand All @@ -40,6 +42,72 @@ var_dump($result);

```

## Requirements and compatibility

Version 2 requires PHP 7.2.5+ (PHP 7 or 8), Guzzle 7.15.2+, and
`overtrue/http` 1.2.3+. Use a currently supported PHP release for production.
The PHP minimum and Guzzle 7 requirement are breaking changes from version 1.

Requests use `https://api.sendcloud.net/apiv2/` by default. Endpoint paths such
as `mail/send` and `/mail/send` are resolved under this base path. An explicitly
configured `base_uri` is honored; include its trailing slash. Absolute URLs
retain Guzzle's normal behavior. Only use trusted endpoints with your API keys. Prefer HTTPS for every configured
or absolute endpoint; an explicitly supplied HTTP URL is not upgraded.

Redirects are disabled by default. If you explicitly set `allow_redirects` to
`true` or a Guzzle redirect-options array, redirects must retain the original
scheme, host, and port. Cross-origin redirects and HTTPS-to-HTTP downgrades throw
a request exception before credentials or payloads can be forwarded. Existing
`on_redirect` callbacks are called only after this check passes.

```php
$client = new SendCloud($apiUser, $apiKey, [
'base_uri' => 'https://api.sendcloud.net/apiv2/',
'response_type' => 'array',
]);
```

POST form requests carry authentication in the form body. Use `upload()` for
attachments; authentication is added as multipart fields without URL-encoding
those field values. You can also pass the `multipart` option to the SDK's
`request()` method. Prebuilt multipart bodies and low-level HTTP-client multipart
calls are rejected; use these SDK entry points so caller-supplied authentication
fields can be replaced before encoding. GET authentication is added to the query without removing
other parameters. Non-form request bodies are left unchanged and authentication
is added to their query. Response types and exception behavior remain those of
`overtrue/http`; API-level failures are not converted into exceptions.

```php
$result = $client->upload('mail/send', [
'attachments' => '/path/to/report.pdf',
], [
'from' => 'sender@example.com',
'to' => 'recipient@example.com',
'subject' => 'Report',
'html' => 'Please see the attachment.',
]);

$result = $client->postAsync('mail/send', $message)->wait();
```

See SendCloud's [delivery contract](https://www.sendcloud.net/doc/en/email_v2/send_email/)
and [GET API contract](https://www.sendcloud.net/doc/en/email_v2/apiuser_do/).

## Tests

```shell
composer install
composer validate --strict
composer test
composer lint
composer audit
```

The dependency-free regression runner uses Guzzle's `MockHandler` and synthetic
credentials. It retains the real authentication middleware and sends no email or
live API requests. CI tests lowest and latest installable dependencies without
disabling Composer's advisory checks.

## License

MIT
8 changes: 7 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
}
},
"require": {
"overtrue/http": "^1.0"
"php": "^7.2.5 || ^8.0",
"guzzlehttp/guzzle": "^7.15.2",
"overtrue/http": "^1.2.3"
},
"scripts": {
"test": "@php tests/run.php",
"lint": "@php tests/lint.php"
}
}
134 changes: 125 additions & 9 deletions src/SendCloud.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,17 @@

namespace Overtrue\SendCloud;

use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\AppendStream;
use GuzzleHttp\Psr7\LimitStream;
use GuzzleHttp\Psr7\MultipartStream;
use GuzzleHttp\Psr7\Stream;
use Overtrue\Http\Client;
use Overtrue\Http\Config;
use Psr\Http\Message\RequestInterface;
use function GuzzleHttp\Psr7\modify_request;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;

/**
* Class SendCloud.
Expand All @@ -22,7 +29,7 @@
*/
class SendCloud extends Client
{
const BASE_URI = 'http://api.sendcloud.net/apiv2/';
const BASE_URI = 'https://api.sendcloud.net/apiv2/';

/**
* @var string
Expand All @@ -46,7 +53,7 @@ public function __construct(string $apiUser, string $apiKey, array $config = [])
$this->apiUser = $apiUser;
$this->apiKey = $apiKey;

$config['base_uri'] = self::BASE_URI;
$config += ['base_uri' => self::BASE_URI, 'allow_redirects' => false];

parent::__construct(new Config($config));

Expand All @@ -60,19 +67,128 @@ public function authorizationParamsMiddleware()
{
return function (callable $handler) {
return function (RequestInterface $request, array $options) use ($handler) {
$credential = http_build_query([
$credentials = [
'apiUser' => $this->apiUser,
'apiKey' => $this->apiKey,
]);
'apiKey' => $this->apiKey,
];
$contentType = strtolower(trim(explode(';', $request->getHeaderLine('Content-Type'))[0]));
$body = $request->getBody();

if ('post' == $request->getMethod()) {
$request = modify_request($request, ['body' => $request->getBody()->getContents().'&'.$credential]);
if ('POST' === strtoupper($request->getMethod()) && $body instanceof MultipartStream) {
if (empty($options['sendcloud_multipart'])) {
throw new \InvalidArgumentException('Use SendCloud::upload() or its multipart request option instead of a prebuilt multipart body.');
}
$parts = [];
foreach ($credentials as $name => $contents) {
$parts[] = compact('name', 'contents');
}
$auth = new MultipartStream($parts, $body->getBoundary());
// Keep the original multipart stream and its closing boundary intact.
$prefix = new LimitStream($auth, $auth->getSize() - strlen('--'.$body->getBoundary()."--\r\n"));
$request = $request->withBody(new AppendStream([$prefix, $body]));
$request = $this->withBodyLength($request);
} elseif ('POST' === strtoupper($request->getMethod())
&& ('application/x-www-form-urlencoded' === $contentType || ('' === $contentType && 0 === $body->getSize()))) {
$stream = new Stream(fopen('php://temp', 'r+'));
$stream->write($this->withCredentials((string) $body, $credentials));
$stream->rewind();
$request = $request->withBody($stream);
if ('' === $contentType) {
$request = $request->withHeader('Content-Type', 'application/x-www-form-urlencoded');
}
$request = $this->withBodyLength($request);
} else {
$request = $request->withUri($request->getUri()->withQuery($credential));
// Leave non-form bodies untouched, preserving unrelated query parameters.
$request = $request->withUri($request->getUri()->withQuery(
$this->withCredentials($request->getUri()->getQuery(), $credentials)
));
}

return $handler($request, $options);
};
};
}

/**
* Guard redirects before Guzzle follows them or authentication is added again.
*/
public function getHandlerStack(): HandlerStack
{
$needsGuard = null === $this->handlerStack;
$stack = parent::getHandlerStack();

if ($needsGuard) {
$stack->before('allow_redirects', function (callable $handler) {
return function (RequestInterface $request, array $options) use ($handler) {
$redirects = $options['allow_redirects'] ?? false;
if (true === $redirects || (is_array($redirects) && $redirects)) {
$redirects = true === $redirects ? [] : $redirects;
$callback = $redirects['on_redirect'] ?? null;
$origin = $request->getUri();
$redirects['on_redirect'] = function (RequestInterface $request, ResponseInterface $response, UriInterface $target) use ($origin, $callback) {
if ($origin->getScheme() !== $target->getScheme()
|| $origin->getHost() !== $target->getHost()
|| $origin->getPort() !== $target->getPort()
|| $origin->getUserInfo() !== $target->getUserInfo()) {
throw new RequestException('SendCloud redirects must stay on the original origin.', $request, $response);
}
if (null !== $callback) {
$callback($request, $response, $target);
}
};
$options['allow_redirects'] = $redirects;
}

return $handler($request, $options);
};
}, 'sendcloud_redirect_guard');
}

return $stack;
}

/**
* Treat API endpoint paths with a leading slash like their relative form.
*/
public function requestRaw(string $uri, string $method = 'GET', array $options = [], bool $async = false)
{
if (isset($uri[0]) && '/' === $uri[0] && substr($uri, 0, 2) !== '//') {
$uri = ltrim($uri, '/');
}

unset($options['sendcloud_multipart']);
if ('POST' === strtoupper($method) && isset($options['multipart'])) {
$options['sendcloud_multipart'] = true;
$options['multipart'] = array_values(array_filter($options['multipart'], function ($part) {
return !isset($part['name']) || !in_array($part['name'], ['apiUser', 'apiKey'], true);
}));
}

return parent::requestRaw($uri, $method, $options, $async);
}

/**
* Preserve encoded values and repeated parameters while replacing credentials.
*/
protected function withCredentials(string $query, array $credentials): string
{
$parameters = array_filter(explode('&', $query), function ($parameter) use ($credentials) {
return '' !== $parameter && !array_key_exists(urldecode(explode('=', $parameter, 2)[0]), $credentials);
});
$parameters[] = http_build_query($credentials, '', '&', PHP_QUERY_RFC3986);

return implode('&', $parameters);
}

protected function withBodyLength(RequestInterface $request): RequestInterface
{
$request = $request->withoutHeader('Content-Length');
$size = $request->getBody()->getSize();

if (null !== $size && !$request->hasHeader('Transfer-Encoding')) {
$request = $request->withHeader('Content-Length', (string) $size);
}

return $request;
}
}
12 changes: 12 additions & 0 deletions tests/lint.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

$failed = false;
foreach (['src', 'tests'] as $directory) {
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__.'/../'.$directory)) as $file) {
if ($file->isFile() && 'php' === $file->getExtension()) {
passthru(escapeshellarg(PHP_BINARY).' -l '.escapeshellarg($file->getPathname()), $status);
$failed = $failed || 0 !== $status;
}
}
}
exit($failed ? 1 : 0);
Loading
Loading