From 8b14a9f461563232cd66beafbff7c593b9d4429b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:23:25 +0000 Subject: [PATCH 1/7] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 603f57a..25ece8f 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 21 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-d9763d006969b49a1473851069fdfa429eb13133b64103a62963bb70ddb22305.yml -openapi_spec_hash: 6aee689b7a759b12c85c088c15e29bc0 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-d868ff00b7b07f6b6802b00f22fad531a91a76bb219a634f3f90fe488bd499ba.yml +openapi_spec_hash: 20e9f2fc31feee78878cdf56e46dab60 config_hash: 5509bb7a961ae2e79114b24c381606d4 From 59582b0bcdd8be588e3f53f062a2ff16d29df00d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:51:44 +0000 Subject: [PATCH 2/7] fix(client): properly generate file params --- src/Core/Conversion.php | 4 +++ src/Core/FileParam.php | 63 +++++++++++++++++++++++++++++++++++++++++ src/Core/Util.php | 60 ++++++++++++++++++++++++++++++--------- 3 files changed, 114 insertions(+), 13 deletions(-) create mode 100644 src/Core/FileParam.php diff --git a/src/Core/Conversion.php b/src/Core/Conversion.php index 75d16ea..1a32035 100644 --- a/src/Core/Conversion.php +++ b/src/Core/Conversion.php @@ -21,6 +21,10 @@ public static function dump_unknown(mixed $value, DumpState $state): mixed } if (is_object($value)) { + if ($value instanceof FileParam) { + return $value; + } + if (is_a($value, class: ConverterSource::class)) { return $value::converter()->dump($value, state: $state); } diff --git a/src/Core/FileParam.php b/src/Core/FileParam.php new file mode 100644 index 0000000..992b5f0 --- /dev/null +++ b/src/Core/FileParam.php @@ -0,0 +1,63 @@ +files->upload(file: FileParam::fromResource(fopen('data.csv', 'r'))); + * + * // From a string: + * $client->files->upload(file: FileParam::fromString('csv data...', 'data.csv')); + * ``` + */ +final class FileParam +{ + public const DEFAULT_CONTENT_TYPE = 'application/octet-stream'; + + /** + * @param resource|string $data the file content as a resource or string + */ + private function __construct( + public readonly mixed $data, + public readonly string $filename, + public readonly string $contentType = self::DEFAULT_CONTENT_TYPE, + ) {} + + /** + * Create a FileParam from an open resource (e.g. from fopen()). + * + * @param resource $resource an open file resource + * @param string|null $filename Override the filename. Defaults to the resource URI basename. + * @param string $contentType override the content type + */ + public static function fromResource(mixed $resource, ?string $filename = null, string $contentType = self::DEFAULT_CONTENT_TYPE): self + { + if (!is_resource($resource)) { + throw new \InvalidArgumentException('Expected a resource, got '.get_debug_type($resource)); + } + + if (null === $filename) { + $meta = stream_get_meta_data($resource); + $filename = basename($meta['uri'] ?? 'upload'); + } + + return new self($resource, filename: $filename, contentType: $contentType); + } + + /** + * Create a FileParam from a string. + * + * @param string $content the file content + * @param string $filename the filename for the Content-Disposition header + * @param string $contentType override the content type + */ + public static function fromString(string $content, string $filename, string $contentType = self::DEFAULT_CONTENT_TYPE): self + { + return new self($content, filename: $filename, contentType: $contentType); + } +} diff --git a/src/Core/Util.php b/src/Core/Util.php index a319c64..d7fbe57 100644 --- a/src/Core/Util.php +++ b/src/Core/Util.php @@ -283,7 +283,7 @@ public static function withSetBody( if (preg_match('/^multipart\/form-data/', $contentType)) { [$boundary, $gen] = self::encodeMultipartStreaming($body); - $encoded = implode('', iterator_to_array($gen)); + $encoded = implode('', iterator_to_array($gen, preserve_keys: false)); $stream = $factory->createStream($encoded); /** @var RequestInterface */ @@ -447,11 +447,18 @@ private static function writeMultipartContent( ): \Generator { $contentLine = "Content-Type: %s\r\n\r\n"; - if (is_resource($val)) { - yield sprintf($contentLine, $contentType ?? 'application/octet-stream'); - while (!feof($val)) { - if ($read = fread($val, length: self::BUF_SIZE)) { - yield $read; + if ($val instanceof FileParam) { + $ct = $val->contentType ?? $contentType; + + yield sprintf($contentLine, $ct); + $data = $val->data; + if (is_string($data)) { + yield $data; + } else { // resource + while (!feof($data)) { + if ($read = fread($data, length: self::BUF_SIZE)) { + yield $read; + } } } } elseif (is_string($val) || is_numeric($val) || is_bool($val)) { @@ -483,17 +490,48 @@ private static function writeMultipartChunk( yield 'Content-Disposition: form-data'; if (!is_null($key)) { - $name = rawurlencode(self::strVal($key)); + $name = str_replace(['"', "\r", "\n"], replace: '', subject: $key); yield "; name=\"{$name}\""; } + // File uploads require a filename in the Content-Disposition header, + // e.g. `Content-Disposition: form-data; name="file"; filename="data.csv"` + // Without this, many servers will reject the upload with a 400. + if ($val instanceof FileParam) { + $filename = str_replace(['"', "\r", "\n"], replace: '', subject: $val->filename); + + yield "; filename=\"{$filename}\""; + } + yield "\r\n"; foreach (self::writeMultipartContent($val, closing: $closing) as $chunk) { yield $chunk; } } + /** + * Expands list arrays into separate multipart parts, applying the configured array key format. + * + * @param list $closing + * + * @return \Generator + */ + private static function writeMultipartField( + string $boundary, + ?string $key, + mixed $val, + array &$closing + ): \Generator { + if (is_array($val) && array_is_list($val)) { + foreach ($val as $item) { + yield from self::writeMultipartField(boundary: $boundary, key: $key, val: $item, closing: $closing); + } + } else { + yield from self::writeMultipartChunk(boundary: $boundary, key: $key, val: $val, closing: $closing); + } + } + /** * @param bool|int|float|string|resource|\Traversable|array|null $body * @@ -508,14 +546,10 @@ private static function encodeMultipartStreaming(mixed $body): array try { if (is_array($body) || is_object($body)) { foreach ((array) $body as $key => $val) { - foreach (static::writeMultipartChunk(boundary: $boundary, key: $key, val: $val, closing: $closing) as $chunk) { - yield $chunk; - } + yield from static::writeMultipartField(boundary: $boundary, key: $key, val: $val, closing: $closing); } } else { - foreach (static::writeMultipartChunk(boundary: $boundary, key: null, val: $body, closing: $closing) as $chunk) { - yield $chunk; - } + yield from static::writeMultipartField(boundary: $boundary, key: null, val: $body, closing: $closing); } yield "--{$boundary}--\r\n"; From 5257135e3688cfa7d602bcf7211c1fef01181b59 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 07:12:45 +0000 Subject: [PATCH 3/7] fix(client): resolve serialization issue with unions and enums --- src/Core/Attributes/Required.php | 17 +---------------- src/Core/Conversion.php | 15 +++++++++++++++ src/Core/Conversion/EnumOf.php | 15 +++++++++++++-- 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/Core/Attributes/Required.php b/src/Core/Attributes/Required.php index 589a017..92a90dc 100644 --- a/src/Core/Attributes/Required.php +++ b/src/Core/Attributes/Required.php @@ -25,9 +25,6 @@ class Required public readonly bool $nullable; - /** @var array */ - private static array $enumConverters = []; - /** * @param class-string|Converter|string|null $type * @param class-string<\BackedEnum>|Converter|null $enum @@ -52,7 +49,7 @@ public function __construct( $type ??= new MapOf($map); } if (null !== $enum) { - $type ??= $enum instanceof Converter ? $enum : self::enumConverter($enum); + $type ??= $enum instanceof Converter ? $enum : EnumOf::fromBackedEnum($enum); } $this->apiName = $apiName; @@ -60,16 +57,4 @@ public function __construct( $this->optional = false; $this->nullable = $nullable; } - - /** @property class-string<\BackedEnum> $enum */ - private static function enumConverter(string $enum): Converter - { - if (!isset(self::$enumConverters[$enum])) { - // @phpstan-ignore-next-line argument.type - $converter = new EnumOf(array_column($enum::cases(), column_key: 'value')); - self::$enumConverters[$enum] = $converter; - } - - return self::$enumConverters[$enum]; - } } diff --git a/src/Core/Conversion.php b/src/Core/Conversion.php index 1a32035..f835d57 100644 --- a/src/Core/Conversion.php +++ b/src/Core/Conversion.php @@ -8,6 +8,7 @@ use CasParser\Core\Conversion\Contracts\Converter; use CasParser\Core\Conversion\Contracts\ConverterSource; use CasParser\Core\Conversion\DumpState; +use CasParser\Core\Conversion\EnumOf; /** * @internal @@ -65,6 +66,13 @@ public static function coerce(Converter|ConverterSource|string $target, mixed $v return $target->coerce($value, state: $state); } + // BackedEnum class-name targets: wrap in EnumOf so enum values are scored + // against the enum's cases. Without this, tryConvert's default case scores + // any class-name target as `no`, even when the value is a valid enum member. + if (is_a($target, class: \BackedEnum::class, allow_string: true)) { + return EnumOf::fromBackedEnum($target)->coerce($value, state: $state); + } + return self::tryConvert($target, value: $value, state: $state); } @@ -78,6 +86,13 @@ public static function dump(Converter|ConverterSource|string $target, mixed $val return $target::converter()->dump($value, state: $state); } + // BackedEnum class-name targets: wrap in EnumOf so enum values are scored + // against the enum's cases. Without this, tryConvert's default case scores + // any class-name target as `no`, even when the value is a valid enum member. + if (is_a($target, class: \BackedEnum::class, allow_string: true)) { + return EnumOf::fromBackedEnum($target)->dump($value, state: $state); + } + self::tryConvert($target, value: $value, state: $state); return self::dump_unknown($value, state: $state); diff --git a/src/Core/Conversion/EnumOf.php b/src/Core/Conversion/EnumOf.php index c1ae0aa..611a7df 100644 --- a/src/Core/Conversion/EnumOf.php +++ b/src/Core/Conversion/EnumOf.php @@ -14,6 +14,9 @@ final class EnumOf implements Converter { private readonly string $type; + /** @var array, self> */ + private static array $cache = []; + /** * @param list $members */ @@ -26,6 +29,13 @@ public function __construct(private readonly array $members) $this->type = $type; } + /** @param class-string<\BackedEnum> $enum */ + public static function fromBackedEnum(string $enum): self + { + // @phpstan-ignore-next-line argument.type + return self::$cache[$enum] ??= new self(array_column($enum::cases(), column_key: 'value')); + } + public function coerce(mixed $value, CoerceState $state): mixed { $this->tally($value, state: $state); @@ -42,9 +52,10 @@ public function dump(mixed $value, DumpState $state): mixed private function tally(mixed $value, CoerceState|DumpState $state): void { - if (in_array($value, haystack: $this->members, strict: true)) { + $needle = $value instanceof \BackedEnum ? $value->value : $value; + if (in_array($needle, haystack: $this->members, strict: true)) { ++$state->yes; - } elseif ($this->type === gettype($value)) { + } elseif ($this->type === gettype($needle)) { ++$state->maybe; } else { ++$state->no; From 4fe2734cd1aa30730c5192cb8a1e20e0a5b0a5a5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 07:14:57 +0000 Subject: [PATCH 4/7] fix: populate enum-typed properties with enum instances --- src/Core/Conversion/EnumOf.php | 20 +++++++++-- tests/Core/ModelTest.php | 62 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/Core/Conversion/EnumOf.php b/src/Core/Conversion/EnumOf.php index 611a7df..55872cd 100644 --- a/src/Core/Conversion/EnumOf.php +++ b/src/Core/Conversion/EnumOf.php @@ -19,9 +19,12 @@ final class EnumOf implements Converter /** * @param list $members + * @param class-string<\BackedEnum>|null $class */ - public function __construct(private readonly array $members) - { + public function __construct( + private readonly array $members, + private readonly ?string $class = null, + ) { $type = 'NULL'; foreach ($this->members as $member) { $type = gettype($member); @@ -33,13 +36,24 @@ public function __construct(private readonly array $members) public static function fromBackedEnum(string $enum): self { // @phpstan-ignore-next-line argument.type - return self::$cache[$enum] ??= new self(array_column($enum::cases(), column_key: 'value')); + return self::$cache[$enum] ??= new self( + array_column($enum::cases(), column_key: 'value'), + class: $enum, + ); } public function coerce(mixed $value, CoerceState $state): mixed { $this->tally($value, state: $state); + if ($value instanceof \BackedEnum) { + return $value; + } + + if (null !== $this->class && (is_int($value) || is_string($value))) { + return ($this->class)::tryFrom($value) ?? $value; + } + return $value; } diff --git a/tests/Core/ModelTest.php b/tests/Core/ModelTest.php index 033b9d1..c551866 100644 --- a/tests/Core/ModelTest.php +++ b/tests/Core/ModelTest.php @@ -47,6 +47,30 @@ public function __construct( } } +enum TicketPriority: string +{ + case Low = 'low'; + case High = 'high'; +} + +class Ticket implements BaseModel +{ + /** @use SdkModel> */ + use SdkModel; + + #[Required(enum: TicketPriority::class)] + public TicketPriority $priority; + + /** @var list */ + #[Required(list: TicketPriority::class)] + public array $labels; + + public function __construct() + { + $this->initialize(); + } +} + /** * @internal * @@ -141,4 +165,42 @@ public function testSerializeModelWithExplicitNull(): void json_encode($model) ); } + + #[Test] + public function testScalarEnumCoercesToInstance(): void + { + $model = Ticket::fromArray(['priority' => 'low', 'labels' => []]); + $this->assertSame(TicketPriority::Low, $model->priority); + } + + #[Test] + public function testListOfEnumCoercesElementsToInstances(): void + { + $model = Ticket::fromArray(['priority' => 'low', 'labels' => ['low', 'high']]); + $this->assertCount(2, $model->labels); + $this->assertSame(TicketPriority::Low, $model->labels[0]); + $this->assertSame(TicketPriority::High, $model->labels[1]); + } + + #[Test] + public function testEnumInstancePassesThrough(): void + { + $model = Ticket::fromArray(['priority' => TicketPriority::High, 'labels' => []]); + $this->assertSame(TicketPriority::High, $model->priority); + } + + #[Test] + public function testInvalidEnumScalarFallsBackToData(): void + { + $model = Ticket::fromArray(['priority' => 'urgent', 'labels' => []]); + $this->assertSame('urgent', $model['priority']); + } + + #[Test] + public function testEnumWireFormatStableAcrossConstruction(): void + { + $fromScalar = Ticket::fromArray(['priority' => 'low', 'labels' => ['high']]); + $fromInstance = Ticket::fromArray(['priority' => TicketPriority::Low, 'labels' => [TicketPriority::High]]); + $this->assertSame(json_encode($fromScalar), json_encode($fromInstance)); + } } From 2892c8f1f0d9cc800e8e03e9899c7f7cef3185c3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:25:12 +0000 Subject: [PATCH 5/7] feat(api): api update --- .stats.yml | 4 +- src/InboundEmail/InboundEmailCreateParams.php | 102 +++++++----------- src/InboundEmail/InboundEmailGetResponse.php | 10 +- .../InboundEmailListResponse/InboundEmail.php | 10 +- src/InboundEmail/InboundEmailNewResponse.php | 10 +- src/ServiceContracts/InboundEmailContract.php | 17 ++- src/Services/InboundEmailRawService.php | 22 ++-- src/Services/InboundEmailService.php | 43 ++++---- tests/Services/InboundEmailTest.php | 23 +--- 9 files changed, 94 insertions(+), 147 deletions(-) diff --git a/.stats.yml b/.stats.yml index 25ece8f..9c6cf93 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 21 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-d868ff00b7b07f6b6802b00f22fad531a91a76bb219a634f3f90fe488bd499ba.yml -openapi_spec_hash: 20e9f2fc31feee78878cdf56e46dab60 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-78ef474b9e171a3eaa430a9dacdc2fa5c7f7d5f89147cb20573a355d3dbb9f0e.yml +openapi_spec_hash: 11b6e43ef4ed724f9804c9d790a4faee config_hash: 5509bb7a961ae2e79114b24c381606d4 diff --git a/src/InboundEmail/InboundEmailCreateParams.php b/src/InboundEmail/InboundEmailCreateParams.php index ee42c80..22fd0c4 100644 --- a/src/InboundEmail/InboundEmailCreateParams.php +++ b/src/InboundEmail/InboundEmailCreateParams.php @@ -5,34 +5,27 @@ namespace CasParser\InboundEmail; use CasParser\Core\Attributes\Optional; -use CasParser\Core\Attributes\Required; use CasParser\Core\Concerns\SdkModel; use CasParser\Core\Concerns\SdkParams; use CasParser\Core\Contracts\BaseModel; use CasParser\InboundEmail\InboundEmailCreateParams\AllowedSource; /** - * Create a dedicated inbound email address for collecting CAS statements via email forwarding. + * Create a dedicated inbound email address for collecting CAS statements + * via email forwarding. When an investor forwards a CAS email to this + * address, we verify the sender and make the file available to you. * - * **How it works:** - * 1. Create an inbound email with your webhook URL - * 2. Display the email address to your user (e.g., "Forward your CAS to ie_xxx@import.casparser.in") - * 3. When an investor forwards a CAS email, we verify the sender and deliver to your webhook - * - * **Webhook Delivery:** - * - We POST to your `callback_url` with JSON body containing files (matching EmailCASFile schema) - * - Failed deliveries are retried automatically with exponential backoff - * - * **Inactivity:** - * - Inbound emails with no activity in 30 days are marked inactive - * - Active inbound emails remain operational indefinitely + * `callback_url` is **optional**: + * - **Set it** — we POST each parsed email to your webhook as it arrives. + * - **Omit it** — retrieve files via `GET /v4/inbound-email/{id}/files` + * without building a webhook consumer. * * @see CasParser\Services\InboundEmailService::create() * * @phpstan-type InboundEmailCreateParamsShape = array{ - * callbackURL: string, * alias?: string|null, * allowedSources?: list>|null, + * callbackURL?: string|null, * metadata?: array|null, * reference?: string|null, * } @@ -44,19 +37,10 @@ final class InboundEmailCreateParams implements BaseModel use SdkParams; /** - * Webhook URL where we POST email notifications. - * Must be HTTPS in production (HTTP allowed for localhost during development). - */ - #[Required('callback_url')] - public string $callbackURL; - - /** - * Optional custom email prefix for user-friendly addresses. - * - Must be 3-32 characters - * - Alphanumeric + hyphens only - * - Must start and end with letter/number - * - Example: `john-portfolio@import.casparser.in` - * - If omitted, generates random ID like `ie_abc123xyz@import.casparser.in`. + * Optional custom email prefix (e.g. + * `john-portfolio@import.casparser.in`). 3-32 chars, + * alphanumeric + hyphens, must start/end with a letter or + * number. If omitted, a random ID is generated. */ #[Optional] public ?string $alias; @@ -73,6 +57,14 @@ final class InboundEmailCreateParams implements BaseModel #[Optional('allowed_sources', list: AllowedSource::class)] public ?array $allowedSources; + /** + * Optional webhook URL where we POST parsed emails. Must be + * HTTPS in production (HTTP allowed for localhost). If omitted, + * retrieve files via `GET /v4/inbound-email/{id}/files`. + */ + #[Optional('callback_url', nullable: true)] + public ?string $callbackURL; + /** * Optional key-value pairs (max 10) to include in webhook payload. * Useful for passing context like plan_type, campaign_id, etc. @@ -89,20 +81,6 @@ final class InboundEmailCreateParams implements BaseModel #[Optional] public ?string $reference; - /** - * `new InboundEmailCreateParams()` is missing required properties by the API. - * - * To enforce required parameters use - * ``` - * InboundEmailCreateParams::with(callbackURL: ...) - * ``` - * - * Otherwise ensure the following setters are called - * - * ``` - * (new InboundEmailCreateParams)->withCallbackURL(...) - * ``` - */ public function __construct() { $this->initialize(); @@ -117,18 +95,17 @@ public function __construct() * @param array|null $metadata */ public static function with( - string $callbackURL, ?string $alias = null, ?array $allowedSources = null, + ?string $callbackURL = null, ?array $metadata = null, ?string $reference = null, ): self { $self = new self; - $self['callbackURL'] = $callbackURL; - null !== $alias && $self['alias'] = $alias; null !== $allowedSources && $self['allowedSources'] = $allowedSources; + null !== $callbackURL && $self['callbackURL'] = $callbackURL; null !== $metadata && $self['metadata'] = $metadata; null !== $reference && $self['reference'] = $reference; @@ -136,24 +113,10 @@ public static function with( } /** - * Webhook URL where we POST email notifications. - * Must be HTTPS in production (HTTP allowed for localhost during development). - */ - public function withCallbackURL(string $callbackURL): self - { - $self = clone $this; - $self['callbackURL'] = $callbackURL; - - return $self; - } - - /** - * Optional custom email prefix for user-friendly addresses. - * - Must be 3-32 characters - * - Alphanumeric + hyphens only - * - Must start and end with letter/number - * - Example: `john-portfolio@import.casparser.in` - * - If omitted, generates random ID like `ie_abc123xyz@import.casparser.in`. + * Optional custom email prefix (e.g. + * `john-portfolio@import.casparser.in`). 3-32 chars, + * alphanumeric + hyphens, must start/end with a letter or + * number. If omitted, a random ID is generated. */ public function withAlias(string $alias): self { @@ -180,6 +143,19 @@ public function withAllowedSources(array $allowedSources): self return $self; } + /** + * Optional webhook URL where we POST parsed emails. Must be + * HTTPS in production (HTTP allowed for localhost). If omitted, + * retrieve files via `GET /v4/inbound-email/{id}/files`. + */ + public function withCallbackURL(?string $callbackURL): self + { + $self = clone $this; + $self['callbackURL'] = $callbackURL; + + return $self; + } + /** * Optional key-value pairs (max 10) to include in webhook payload. * Useful for passing context like plan_type, campaign_id, etc. diff --git a/src/InboundEmail/InboundEmailGetResponse.php b/src/InboundEmail/InboundEmailGetResponse.php index 5e76c16..f692457 100644 --- a/src/InboundEmail/InboundEmailGetResponse.php +++ b/src/InboundEmail/InboundEmailGetResponse.php @@ -39,9 +39,10 @@ final class InboundEmailGetResponse implements BaseModel public ?array $allowedSources; /** - * Webhook URL for email notifications. + * Webhook URL for email notifications. `null` means files are only + * retrievable via `GET /v4/inbound-email/{id}/files` (pull delivery). */ - #[Optional('callback_url')] + #[Optional('callback_url', nullable: true)] public ?string $callbackURL; /** @@ -144,9 +145,10 @@ public function withAllowedSources(array $allowedSources): self } /** - * Webhook URL for email notifications. + * Webhook URL for email notifications. `null` means files are only + * retrievable via `GET /v4/inbound-email/{id}/files` (pull delivery). */ - public function withCallbackURL(string $callbackURL): self + public function withCallbackURL(?string $callbackURL): self { $self = clone $this; $self['callbackURL'] = $callbackURL; diff --git a/src/InboundEmail/InboundEmailListResponse/InboundEmail.php b/src/InboundEmail/InboundEmailListResponse/InboundEmail.php index 5328b37..6ee9453 100644 --- a/src/InboundEmail/InboundEmailListResponse/InboundEmail.php +++ b/src/InboundEmail/InboundEmailListResponse/InboundEmail.php @@ -39,9 +39,10 @@ final class InboundEmail implements BaseModel public ?array $allowedSources; /** - * Webhook URL for email notifications. + * Webhook URL for email notifications. `null` means files are only + * retrievable via `GET /v4/inbound-email/{id}/files` (pull delivery). */ - #[Optional('callback_url')] + #[Optional('callback_url', nullable: true)] public ?string $callbackURL; /** @@ -144,9 +145,10 @@ public function withAllowedSources(array $allowedSources): self } /** - * Webhook URL for email notifications. + * Webhook URL for email notifications. `null` means files are only + * retrievable via `GET /v4/inbound-email/{id}/files` (pull delivery). */ - public function withCallbackURL(string $callbackURL): self + public function withCallbackURL(?string $callbackURL): self { $self = clone $this; $self['callbackURL'] = $callbackURL; diff --git a/src/InboundEmail/InboundEmailNewResponse.php b/src/InboundEmail/InboundEmailNewResponse.php index 4a3daf0..f5fe80f 100644 --- a/src/InboundEmail/InboundEmailNewResponse.php +++ b/src/InboundEmail/InboundEmailNewResponse.php @@ -39,9 +39,10 @@ final class InboundEmailNewResponse implements BaseModel public ?array $allowedSources; /** - * Webhook URL for email notifications. + * Webhook URL for email notifications. `null` means files are only + * retrievable via `GET /v4/inbound-email/{id}/files` (pull delivery). */ - #[Optional('callback_url')] + #[Optional('callback_url', nullable: true)] public ?string $callbackURL; /** @@ -144,9 +145,10 @@ public function withAllowedSources(array $allowedSources): self } /** - * Webhook URL for email notifications. + * Webhook URL for email notifications. `null` means files are only + * retrievable via `GET /v4/inbound-email/{id}/files` (pull delivery). */ - public function withCallbackURL(string $callbackURL): self + public function withCallbackURL(?string $callbackURL): self { $self = clone $this; $self['callbackURL'] = $callbackURL; diff --git a/src/ServiceContracts/InboundEmailContract.php b/src/ServiceContracts/InboundEmailContract.php index 836f9c4..c81a3c6 100644 --- a/src/ServiceContracts/InboundEmailContract.php +++ b/src/ServiceContracts/InboundEmailContract.php @@ -21,19 +21,18 @@ interface InboundEmailContract /** * @api * - * @param string $callbackURL Webhook URL where we POST email notifications. - * Must be HTTPS in production (HTTP allowed for localhost during development). - * @param string $alias Optional custom email prefix for user-friendly addresses. - * - Must be 3-32 characters - * - Alphanumeric + hyphens only - * - Must start and end with letter/number - * - Example: `john-portfolio@import.casparser.in` - * - If omitted, generates random ID like `ie_abc123xyz@import.casparser.in` + * @param string $alias Optional custom email prefix (e.g. + * `john-portfolio@import.casparser.in`). 3-32 chars, + * alphanumeric + hyphens, must start/end with a letter or + * number. If omitted, a random ID is generated. * @param list> $allowedSources Filter emails by CAS provider. If omitted, accepts all providers. * - `cdsl` → eCAS@cdslstatement.com * - `nsdl` → NSDL-CAS@nsdl.co.in * - `cams` → donotreply@camsonline.com * - `kfintech` → samfS@kfintech.com + * @param string|null $callbackURL Optional webhook URL where we POST parsed emails. Must be + * HTTPS in production (HTTP allowed for localhost). If omitted, + * retrieve files via `GET /v4/inbound-email/{id}/files`. * @param array $metadata Optional key-value pairs (max 10) to include in webhook payload. * Useful for passing context like plan_type, campaign_id, etc. * @param string $reference Your internal identifier (e.g., user_id, account_id). @@ -43,9 +42,9 @@ interface InboundEmailContract * @throws APIException */ public function create( - string $callbackURL, ?string $alias = null, ?array $allowedSources = null, + ?string $callbackURL = null, ?array $metadata = null, ?string $reference = null, RequestOptions|array|null $requestOptions = null, diff --git a/src/Services/InboundEmailRawService.php b/src/Services/InboundEmailRawService.php index 235eaac..87f5cd0 100644 --- a/src/Services/InboundEmailRawService.php +++ b/src/Services/InboundEmailRawService.php @@ -53,25 +53,19 @@ public function __construct(private Client $client) {} /** * @api * - * Create a dedicated inbound email address for collecting CAS statements via email forwarding. + * Create a dedicated inbound email address for collecting CAS statements + * via email forwarding. When an investor forwards a CAS email to this + * address, we verify the sender and make the file available to you. * - * **How it works:** - * 1. Create an inbound email with your webhook URL - * 2. Display the email address to your user (e.g., "Forward your CAS to ie_xxx@import.casparser.in") - * 3. When an investor forwards a CAS email, we verify the sender and deliver to your webhook - * - * **Webhook Delivery:** - * - We POST to your `callback_url` with JSON body containing files (matching EmailCASFile schema) - * - Failed deliveries are retried automatically with exponential backoff - * - * **Inactivity:** - * - Inbound emails with no activity in 30 days are marked inactive - * - Active inbound emails remain operational indefinitely + * `callback_url` is **optional**: + * - **Set it** — we POST each parsed email to your webhook as it arrives. + * - **Omit it** — retrieve files via `GET /v4/inbound-email/{id}/files` + * without building a webhook consumer. * * @param array{ - * callbackURL: string, * alias?: string, * allowedSources?: list>, + * callbackURL?: string|null, * metadata?: array, * reference?: string, * }|InboundEmailCreateParams $params diff --git a/src/Services/InboundEmailService.php b/src/Services/InboundEmailService.php index 6a09685..33db555 100644 --- a/src/Services/InboundEmailService.php +++ b/src/Services/InboundEmailService.php @@ -58,34 +58,27 @@ public function __construct(private Client $client) /** * @api * - * Create a dedicated inbound email address for collecting CAS statements via email forwarding. - * - * **How it works:** - * 1. Create an inbound email with your webhook URL - * 2. Display the email address to your user (e.g., "Forward your CAS to ie_xxx@import.casparser.in") - * 3. When an investor forwards a CAS email, we verify the sender and deliver to your webhook - * - * **Webhook Delivery:** - * - We POST to your `callback_url` with JSON body containing files (matching EmailCASFile schema) - * - Failed deliveries are retried automatically with exponential backoff - * - * **Inactivity:** - * - Inbound emails with no activity in 30 days are marked inactive - * - Active inbound emails remain operational indefinitely - * - * @param string $callbackURL Webhook URL where we POST email notifications. - * Must be HTTPS in production (HTTP allowed for localhost during development). - * @param string $alias Optional custom email prefix for user-friendly addresses. - * - Must be 3-32 characters - * - Alphanumeric + hyphens only - * - Must start and end with letter/number - * - Example: `john-portfolio@import.casparser.in` - * - If omitted, generates random ID like `ie_abc123xyz@import.casparser.in` + * Create a dedicated inbound email address for collecting CAS statements + * via email forwarding. When an investor forwards a CAS email to this + * address, we verify the sender and make the file available to you. + * + * `callback_url` is **optional**: + * - **Set it** — we POST each parsed email to your webhook as it arrives. + * - **Omit it** — retrieve files via `GET /v4/inbound-email/{id}/files` + * without building a webhook consumer. + * + * @param string $alias Optional custom email prefix (e.g. + * `john-portfolio@import.casparser.in`). 3-32 chars, + * alphanumeric + hyphens, must start/end with a letter or + * number. If omitted, a random ID is generated. * @param list> $allowedSources Filter emails by CAS provider. If omitted, accepts all providers. * - `cdsl` → eCAS@cdslstatement.com * - `nsdl` → NSDL-CAS@nsdl.co.in * - `cams` → donotreply@camsonline.com * - `kfintech` → samfS@kfintech.com + * @param string|null $callbackURL Optional webhook URL where we POST parsed emails. Must be + * HTTPS in production (HTTP allowed for localhost). If omitted, + * retrieve files via `GET /v4/inbound-email/{id}/files`. * @param array $metadata Optional key-value pairs (max 10) to include in webhook payload. * Useful for passing context like plan_type, campaign_id, etc. * @param string $reference Your internal identifier (e.g., user_id, account_id). @@ -95,18 +88,18 @@ public function __construct(private Client $client) * @throws APIException */ public function create( - string $callbackURL, ?string $alias = null, ?array $allowedSources = null, + ?string $callbackURL = null, ?array $metadata = null, ?string $reference = null, RequestOptions|array|null $requestOptions = null, ): InboundEmailNewResponse { $params = Util::removeNulls( [ - 'callbackURL' => $callbackURL, 'alias' => $alias, 'allowedSources' => $allowedSources, + 'callbackURL' => $callbackURL, 'metadata' => $metadata, 'reference' => $reference, ], diff --git a/tests/Services/InboundEmailTest.php b/tests/Services/InboundEmailTest.php index bd12df6..ffa3c0e 100644 --- a/tests/Services/InboundEmailTest.php +++ b/tests/Services/InboundEmailTest.php @@ -38,28 +38,7 @@ public function testCreate(): void $this->markTestSkipped('Mock server tests are disabled'); } - $result = $this->client->inboundEmail->create( - callbackURL: 'https://api.yourapp.com/webhooks/cas-email' - ); - - // @phpstan-ignore-next-line method.alreadyNarrowedType - $this->assertInstanceOf(InboundEmailNewResponse::class, $result); - } - - #[Test] - public function testCreateWithOptionalParams(): void - { - if (UnsupportedMockTests::$skip) { - $this->markTestSkipped('Mock server tests are disabled'); - } - - $result = $this->client->inboundEmail->create( - callbackURL: 'https://api.yourapp.com/webhooks/cas-email', - alias: 'john-portfolio', - allowedSources: ['cdsl', 'nsdl'], - metadata: ['plan' => 'premium', 'source' => 'onboarding'], - reference: 'user_12345', - ); + $result = $this->client->inboundEmail->create(); // @phpstan-ignore-next-line method.alreadyNarrowedType $this->assertInstanceOf(InboundEmailNewResponse::class, $result); From 5550179c5e002c9d2e91e912b8fee39e4af803fd Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:25:19 +0000 Subject: [PATCH 6/7] feat(api): api update --- .stats.yml | 4 ++-- src/ServiceContracts/InboundEmailContract.php | 2 +- src/ServiceContracts/InboundEmailRawContract.php | 2 +- src/Services/InboundEmailRawService.php | 2 +- src/Services/InboundEmailService.php | 2 +- tests/Services/InboundEmailTest.php | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.stats.yml b/.stats.yml index 9c6cf93..e26d438 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 21 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-78ef474b9e171a3eaa430a9dacdc2fa5c7f7d5f89147cb20573a355d3dbb9f0e.yml -openapi_spec_hash: 11b6e43ef4ed724f9804c9d790a4faee +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-e5c0c65637cdf3a6c4360b8193973b73a3d35ad1056ef607c3319ef03e591a55.yml +openapi_spec_hash: 7515d1e5fe3130b9f5411f7aacbc8a64 config_hash: 5509bb7a961ae2e79114b24c381606d4 diff --git a/src/ServiceContracts/InboundEmailContract.php b/src/ServiceContracts/InboundEmailContract.php index c81a3c6..f13f782 100644 --- a/src/ServiceContracts/InboundEmailContract.php +++ b/src/ServiceContracts/InboundEmailContract.php @@ -53,7 +53,7 @@ public function create( /** * @api * - * @param string $inboundEmailID Inbound Email ID (e.g., ie_a1b2c3d4e5f6) + * @param string $inboundEmailID Inbound Email ID * @param RequestOpts|null $requestOptions * * @throws APIException diff --git a/src/ServiceContracts/InboundEmailRawContract.php b/src/ServiceContracts/InboundEmailRawContract.php index 80f74dd..7fc47ae 100644 --- a/src/ServiceContracts/InboundEmailRawContract.php +++ b/src/ServiceContracts/InboundEmailRawContract.php @@ -37,7 +37,7 @@ public function create( /** * @api * - * @param string $inboundEmailID Inbound Email ID (e.g., ie_a1b2c3d4e5f6) + * @param string $inboundEmailID Inbound Email ID * @param RequestOpts|null $requestOptions * * @return BaseResponse diff --git a/src/Services/InboundEmailRawService.php b/src/Services/InboundEmailRawService.php index 87f5cd0..f42c0ee 100644 --- a/src/Services/InboundEmailRawService.php +++ b/src/Services/InboundEmailRawService.php @@ -99,7 +99,7 @@ public function create( * * Retrieve details of a specific mailbox including statistics. * - * @param string $inboundEmailID Inbound Email ID (e.g., ie_a1b2c3d4e5f6) + * @param string $inboundEmailID Inbound Email ID * @param RequestOpts|null $requestOptions * * @return BaseResponse diff --git a/src/Services/InboundEmailService.php b/src/Services/InboundEmailService.php index 33db555..3506afb 100644 --- a/src/Services/InboundEmailService.php +++ b/src/Services/InboundEmailService.php @@ -116,7 +116,7 @@ public function create( * * Retrieve details of a specific mailbox including statistics. * - * @param string $inboundEmailID Inbound Email ID (e.g., ie_a1b2c3d4e5f6) + * @param string $inboundEmailID Inbound Email ID * @param RequestOpts|null $requestOptions * * @throws APIException diff --git a/tests/Services/InboundEmailTest.php b/tests/Services/InboundEmailTest.php index ffa3c0e..3d4fd3d 100644 --- a/tests/Services/InboundEmailTest.php +++ b/tests/Services/InboundEmailTest.php @@ -51,7 +51,7 @@ public function testRetrieve(): void $this->markTestSkipped('Mock server tests are disabled'); } - $result = $this->client->inboundEmail->retrieve('ie_a1b2c3d4e5f6'); + $result = $this->client->inboundEmail->retrieve('inbound_email_id'); // @phpstan-ignore-next-line method.alreadyNarrowedType $this->assertInstanceOf(InboundEmailGetResponse::class, $result); From 620ee9cf610df9bb49afeb10764fd84d71b3657f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:25:39 +0000 Subject: [PATCH 7/7] release: 0.7.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 16 ++++++++++++++++ src/Version.php | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index e3778b2..1b77f50 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.6.2" + ".": "0.7.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d16a6a..841b745 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.7.0 (2026-04-19) + +Full Changelog: [v0.6.2...v0.7.0](https://github.com/CASParser/cas-parser-php/compare/v0.6.2...v0.7.0) + +### Features + +* **api:** api update ([5550179](https://github.com/CASParser/cas-parser-php/commit/5550179c5e002c9d2e91e912b8fee39e4af803fd)) +* **api:** api update ([2892c8f](https://github.com/CASParser/cas-parser-php/commit/2892c8f1f0d9cc800e8e03e9899c7f7cef3185c3)) + + +### Bug Fixes + +* **client:** properly generate file params ([59582b0](https://github.com/CASParser/cas-parser-php/commit/59582b0bcdd8be588e3f53f062a2ff16d29df00d)) +* **client:** resolve serialization issue with unions and enums ([5257135](https://github.com/CASParser/cas-parser-php/commit/5257135e3688cfa7d602bcf7211c1fef01181b59)) +* populate enum-typed properties with enum instances ([4fe2734](https://github.com/CASParser/cas-parser-php/commit/4fe2734cd1aa30730c5192cb8a1e20e0a5b0a5a5)) + ## 0.6.2 (2026-03-17) Full Changelog: [v0.6.1...v0.6.2](https://github.com/CASParser/cas-parser-php/compare/v0.6.1...v0.6.2) diff --git a/src/Version.php b/src/Version.php index 0d79fe8..57860bd 100644 --- a/src/Version.php +++ b/src/Version.php @@ -5,5 +5,5 @@ namespace CasParser; // x-release-please-start-version -const VERSION = '0.6.2'; +const VERSION = '0.7.0'; // x-release-please-end