fix(publish): never surface raw internal errors in the failure email

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/<id>/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.
This commit is contained in:
Paulo Castellano 2026-06-28 20:02:35 -03:00
parent 4417aa99ac
commit 08e64ae074
2 changed files with 62 additions and 6 deletions

View file

@ -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();
}

View file

@ -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 () {