From 08e64ae0748809f12a037f1f93084e5bcb9bc62e Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 28 Jun 2026 20:02:35 -0300 Subject: [PATCH] fix(publish): never surface raw internal errors in the failure email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-failure email renders each platform's stored error_message verbatim. On an unexpected publish error the job's catch-all (and the job-failed hook) stored the raw exception message — for a PHP TypeError that includes the server file path (.../releases//app/Services/Social/XPublisher.php on line 130), which then reached the customer's inbox. Only our own SocialPublishException carries a vetted, user-facing message. Every other throwable (engine errors like TypeError, or library exceptions such as Intervention's decoder errors which embed temp paths) is now replaced with a generic line; the raw detail stays in the logs. Token-expired and rescheduled paths are unchanged. Note: publishers that still throw plain \Exception for user-facing reasons (some X/Facebook/Threads/LinkedIn cases) will now show the generic message for those; converting them to typed SocialPublishExceptions to restore specific copy is a worthwhile follow-up. --- app/Jobs/PublishToSocialPlatform.php | 27 +++++++++--- .../Jobs/PublishToSocialPlatformTest.php | 41 ++++++++++++++++++- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 5bf96054..eca432f0 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -165,7 +165,7 @@ public function handle(): void 'platform' => $this->postPlatform->platform->value, 'error' => $e->getMessage(), ]); - $this->postPlatform->markAsFailed($e->getMessage(), [ + $this->postPlatform->markAsFailed($this->safeFailureMessage($e), [ 'category' => 'unknown', 'failed_at' => now()->toIso8601String(), 'content_length' => mb_strlen($this->postPlatform->post->content ?? ''), @@ -223,6 +223,20 @@ private function broadcastStatus(): void PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh()); } + /** + * A failure message safe to surface to the user — it ends up in the + * post-failure email. Only our own publish exceptions carry a vetted + * user-facing message; any other throwable (engine errors like TypeError, + * or library exceptions) can embed file paths and internals, so it's + * replaced with a generic line. The raw detail stays in the logs. + */ + private function safeFailureMessage(\Throwable $e): string + { + return $e instanceof SocialPublishException + ? $e->userMessage + : 'An unexpected error occurred while publishing. Please try again.'; + } + private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher { return match ($this->postPlatform->platform) { @@ -308,10 +322,13 @@ public function failed(?\Throwable $exception): void $this->postPlatform->refresh(); if ($this->postPlatform->status !== PostPlatformStatus::Published) { - $this->postPlatform->markAsFailed($exception?->getMessage() ?? 'Unknown error', [ - 'category' => 'job_failed', - 'failed_at' => now()->toIso8601String(), - ]); + $this->postPlatform->markAsFailed( + $exception ? $this->safeFailureMessage($exception) : 'Unknown error', + [ + 'category' => 'job_failed', + 'failed_at' => now()->toIso8601String(), + ] + ); $this->updatePostStatus(); $this->broadcastStatus(); } diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index cb3ff6d6..d3d955e7 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -181,7 +181,46 @@ $this->postPlatform->refresh(); expect($this->postPlatform->status)->toBe(PlatformStatus::Failed); - expect($this->postPlatform->error_message)->toBe('API Error'); + // Untrusted exceptions are genericized — only vetted publish exceptions surface their message. + expect($this->postPlatform->error_message)->toBe('An unexpected error occurred while publishing. Please try again.'); +}); + +test('publish keeps the vetted user message from a publish exception', function () { + Event::fake(); + + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->andThrow(new LinkedInPublishException( + userMessage: 'LinkedIn rejected this post.', + category: ErrorCategory::ContentPolicy, + )); + + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + $this->postPlatform->refresh(); + expect($this->postPlatform->status)->toBe(PlatformStatus::Failed); + expect($this->postPlatform->error_message)->toBe('LinkedIn rejected this post.'); +}); + +test('publish never leaks a raw internal error to the failure record (and the email)', function () { + Event::fake(); + + // A PHP TypeError embeds the server file path in its message; it must not reach the user. + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->andThrow(new TypeError( + 'X::getMediaCategory(): Argument #1 ($mimeType) must be of type string, null given, called in /home/forge/app.trypost.it/releases/72198060/app/Services/Social/XPublisher.php on line 130' + )); + + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + $this->postPlatform->refresh(); + expect($this->postPlatform->status)->toBe(PlatformStatus::Failed); + expect($this->postPlatform->error_message)->toBe('An unexpected error occurred while publishing. Please try again.') + ->and($this->postPlatform->error_message)->not->toContain('/home/forge') + ->and($this->postPlatform->error_message)->not->toContain('getMediaCategory'); }); test('publish to social platform marks account as token expired on auth failure', function () {