From 3cecae1690c793ced90d6f22e7ab04bac21953b3 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 17 Aug 2026 17:39:34 +0100 Subject: [PATCH 1/4] add the concept of spam form submissions Co-Authored-By: Claude Fable 5 --- resources/css/components/index-fields.css | 3 +- .../forms/SubmissionStatusIndicator.vue | 4 +- src/Forms/Form.php | 2 +- src/Forms/Submission.php | 31 +++++++++++-- src/Jobs/DeletePartialFormSubmissions.php | 1 + src/Query/Scopes/Filters/SubmissionStatus.php | 6 ++- .../DeletePartialFormSubmissionsTest.php | 21 +++++++++ tests/Forms/SubmissionStatusFilterTest.php | 43 +++++++++++++++++++ tests/Forms/SubmissionTest.php | 21 +++++++-- 9 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 tests/Forms/SubmissionStatusFilterTest.php diff --git a/resources/css/components/index-fields.css b/resources/css/components/index-fields.css index d8ba1271ab6..5ca6d6223f1 100644 --- a/resources/css/components/index-fields.css +++ b/resources/css/components/index-fields.css @@ -33,7 +33,8 @@ @apply bg-green-200 text-green-900 dark:bg-green-300/6 dark:text-green-300; } - &.status-scheduled { + &.status-scheduled, + &.status-spam { @apply bg-amber-200 text-amber-900 dark:bg-amber-300/6 dark:text-amber-300; } } diff --git a/resources/js/components/forms/SubmissionStatusIndicator.vue b/resources/js/components/forms/SubmissionStatusIndicator.vue index 95160531530..2e3b2cc215f 100644 --- a/resources/js/components/forms/SubmissionStatusIndicator.vue +++ b/resources/js/components/forms/SubmissionStatusIndicator.vue @@ -6,7 +6,7 @@ const props = defineProps({ type: String, required: false, default: 'finalized', - validator: (value) => ['finalized', 'partial'].includes(value), + validator: (value) => ['finalized', 'partial', 'spam'].includes(value), }, showDot: { type: Boolean, default: true }, showLabel: { type: Boolean, default: false }, @@ -16,6 +16,7 @@ const statusClass = computed(() => { return { finalized: 'bg-green-400', partial: 'bg-gray-300 dark:bg-gray-200', + spam: 'bg-amber-400', }[props.status]; }); @@ -23,6 +24,7 @@ const label = computed(() => { return { finalized: __('Finalized'), partial: __('Partial'), + spam: __('Spam'), }[props.status]; }); diff --git a/src/Forms/Form.php b/src/Forms/Form.php index 30ec825563d..b4a34d23c16 100644 --- a/src/Forms/Form.php +++ b/src/Forms/Form.php @@ -523,7 +523,7 @@ private function submissionLimitReached(): bool private function submissionCount(): int { - $query = $this->querySubmissions()->whereNull('partial'); + $query = $this->querySubmissions()->whereNull('partial')->whereNull('spam'); if ($start = $this->submissionLimitPeriodStart()) { $query->where('date', '>=', $start); diff --git a/src/Forms/Submission.php b/src/Forms/Submission.php index 0999c583f5a..5016e5c7f6c 100644 --- a/src/Forms/Submission.php +++ b/src/Forms/Submission.php @@ -70,9 +70,9 @@ public function data($data = null) $data = collect($data); // A full data replacement would otherwise drop the internal lifecycle - // keys, so carry over the existing partial and site values unless the - // incoming payload provides its own. - foreach (['partial', 'site'] as $key) { + // keys, so carry over the existing partial, spam, and site values + // unless the incoming payload provides its own. + foreach (['partial', 'spam', 'site'] as $key) { if ($this->has($key) && ! $data->has($key)) { $data[$key] = $this->get($key); } @@ -164,12 +164,35 @@ public function asPartial(): self public function isPartial(): bool { - return (bool) $this->get('partial'); + // Spam submissions aren't in progress, so they shouldn't be resumed. + // The "partial" key sticks around to indicate that the submission was + // never finalized, so marking it as not spam can finalize it as normal. + return $this->get('partial') && ! $this->isSpam(); + } + + public function markAsSpam(): self + { + $this->set('spam', true); + + return $this; + } + + public function markAsNotSpam(): self + { + $this->remove('spam'); + + return $this; + } + + public function isSpam(): bool + { + return (bool) $this->get('spam'); } public function status(): string { return match (true) { + $this->isSpam() => 'spam', $this->isPartial() => 'partial', default => 'finalized', }; diff --git a/src/Jobs/DeletePartialFormSubmissions.php b/src/Jobs/DeletePartialFormSubmissions.php index fefbea040a1..442641a1aa6 100644 --- a/src/Jobs/DeletePartialFormSubmissions.php +++ b/src/Jobs/DeletePartialFormSubmissions.php @@ -24,6 +24,7 @@ public function handle(): void FormSubmission::query() ->where('partial', true) + ->whereNull('spam') ->where('date', '<', $threshold) ->get() ->each(function (Submission $submission): void { diff --git a/src/Query/Scopes/Filters/SubmissionStatus.php b/src/Query/Scopes/Filters/SubmissionStatus.php index 87920bc44e6..ebddfc6cb28 100644 --- a/src/Query/Scopes/Filters/SubmissionStatus.php +++ b/src/Query/Scopes/Filters/SubmissionStatus.php @@ -33,8 +33,9 @@ public function autoApply() public function apply($query, $values) { match ($values['status']) { - 'partial' => $query->where('partial', true), - default => $query->where('partial', '!=', true), + 'partial' => $query->where('partial', true)->where('spam', '!=', true), + 'spam' => $query->where('spam', true), + default => $query->where('partial', '!=', true)->where('spam', '!=', true), }; } @@ -53,6 +54,7 @@ protected function options() return collect([ 'finalized' => __('Finalized'), 'partial' => __('Partial'), + 'spam' => __('Spam'), ]); } } diff --git a/tests/Forms/DeletePartialFormSubmissionsTest.php b/tests/Forms/DeletePartialFormSubmissionsTest.php index 65c58a7cecc..3896443e22f 100644 --- a/tests/Forms/DeletePartialFormSubmissionsTest.php +++ b/tests/Forms/DeletePartialFormSubmissionsTest.php @@ -68,6 +68,27 @@ public function it_only_deletes_partial_submissions_never_finalized() $this->assertNotNull($form->submission($finalized->id())); } + #[Test] + public function it_does_not_delete_partial_submissions_marked_as_spam() + { + config(['statamic.forms.delete_partial_submissions_after' => 7]); + + $form = tap(Form::make('contact'))->save(); + + Carbon::setTestNow('2025-06-01 12:00:00'); + $partial = tap($form->makeSubmission()->set('partial', true))->save(); + + Carbon::setTestNow('2025-06-02 12:00:00'); + $spam = tap($form->makeSubmission()->set('partial', true)->markAsSpam())->save(); + + Carbon::setTestNow('2025-06-30 12:00:00'); + + (new DeletePartialFormSubmissions)->handle(); + + $this->assertNull($form->submission($partial->id())); + $this->assertNotNull($form->submission($spam->id())); + } + #[Test] public function it_does_not_delete_anything_when_disabled() { diff --git a/tests/Forms/SubmissionStatusFilterTest.php b/tests/Forms/SubmissionStatusFilterTest.php new file mode 100644 index 00000000000..c6e99f066c7 --- /dev/null +++ b/tests/Forms/SubmissionStatusFilterTest.php @@ -0,0 +1,43 @@ +save(); + + FormSubmission::make()->form($form)->data(['id' => 'finalized'])->save(); + FormSubmission::make()->form($form)->asPartial()->data(['id' => 'partial'])->save(); + FormSubmission::make()->form($form)->markAsSpam()->data(['id' => 'flagged-spam'])->save(); + FormSubmission::make()->form($form)->asPartial()->markAsSpam()->data(['id' => 'unfinalized-spam'])->save(); + + $query = FormSubmission::query()->where('form', 'test'); + + (new SubmissionStatus)->apply($query, ['status' => $status]); + + $this->assertEquals($expected, $query->get()->map->get('id')->sort()->values()->all()); + } + + public static function statusProvider(): array + { + return [ + 'finalized' => ['finalized', ['finalized']], + 'partial' => ['partial', ['partial']], + 'spam' => ['spam', ['flagged-spam', 'unfinalized-spam']], + ]; + } +} diff --git a/tests/Forms/SubmissionTest.php b/tests/Forms/SubmissionTest.php index b6a2200b7a2..84d397137ae 100644 --- a/tests/Forms/SubmissionTest.php +++ b/tests/Forms/SubmissionTest.php @@ -128,16 +128,17 @@ public function it_sets_and_gets_data() } #[Test] - public function setting_data_preserves_the_partial_and_site_keys() + public function setting_data_preserves_the_partial_spam_and_site_keys() { $form = tap(Form::make('contact_us'))->save(); - $submission = $form->makeSubmission()->asPartial()->site('fr'); + $submission = $form->makeSubmission()->asPartial()->markAsSpam()->site('fr'); $submission->data(['foo' => 'bar']); $this->assertEquals('bar', $submission->get('foo')); - $this->assertTrue($submission->isPartial()); + $this->assertTrue($submission->get('partial')); + $this->assertTrue($submission->isSpam()); $this->assertEquals('fr', $submission->get('site')); } @@ -284,6 +285,20 @@ public function it_determines_its_status() $partial = $form->makeSubmission()->asPartial(); $this->assertTrue($partial->isPartial()); $this->assertEquals('partial', $partial->status()); + + $spam = $form->makeSubmission()->markAsSpam(); + $this->assertTrue($spam->isSpam()); + $this->assertEquals('spam', $spam->status()); + + // Submissions caught by spam protection keep their raw partial + // key, but report as spam rather than partial. + $partialSpam = $form->makeSubmission()->asPartial()->markAsSpam(); + $this->assertFalse($partialSpam->isPartial()); + $this->assertEquals('spam', $partialSpam->status()); + + $notSpam = $spam->markAsNotSpam(); + $this->assertFalse($notSpam->isSpam()); + $this->assertEquals('finalized', $notSpam->status()); } #[Test] From 13bc36748db36bfc5e5d7620f9b255a914486a6e Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 17 Aug 2026 17:39:40 +0100 Subject: [PATCH 2/4] add honeypot behavior setting for storing submissions as spam Co-Authored-By: Claude Fable 5 --- lang/en/messages.php | 2 + src/Forms/SubmitForm.php | 21 ++++- .../Controllers/CP/Forms/FormsController.php | 16 +++- src/Http/Controllers/FormController.php | 4 + tests/Feature/Forms/EditFormTest.php | 10 +-- tests/Forms/SubmitFormTest.php | 79 +++++++++++++++++++ tests/Tags/Form/FormCreateTest.php | 45 +++++++++++ 7 files changed, 167 insertions(+), 10 deletions(-) diff --git a/lang/en/messages.php b/lang/en/messages.php index aefafc6ced2..5d6cc14721c 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -133,6 +133,7 @@ 'form_configure_email_text_instructions' => 'The view for the text version of this email.', 'form_configure_email_to_instructions' => 'Email address of the recipient(s) - comma separated.', 'form_configure_handle_instructions' => 'Used to reference this form on the frontend. This cannot be easily changed later.', + 'form_configure_honeypot_behavior_instructions' => 'What happens to submissions caught by the honeypot. They can be silently discarded, or stored and marked as spam for review.', 'form_configure_honeypot_instructions' => 'Field name to use as a honeypot. Honeypots are special fields used to reduce bot spam.', 'form_configure_intro' => 'Forms collect information from visitors and can trigger events and send notifications when submissions are received.', 'form_configure_mailer_instructions' => 'Choose the mailer for sending this email. Leave blank to fall back to the default mailer.', @@ -197,6 +198,7 @@ 'licensing_trial_mode_alert_addons' => 'This site is using commercial addons in trial mode. Valid licenses will be required when you\'re ready to launch.', 'licensing_trial_mode_alert_statamic' => 'Thanks for trying Statamic Pro! This site is currently in trial mode — please enter a license before your site goes live.', 'licensing_utility_description' => 'View and resolve licensing details.', + 'mark_as_not_spam_action_confirmation' => 'Submissions that were caught before being finalized will be finalized, triggering any configured connections.', 'max_depth_instructions' => 'Set the max page nesting level.', 'max_items_instructions' => 'Set a maximum number of selectable items.', 'navigation_configure_blueprint_instructions' => 'Choose from existing blueprints or create a new one.', diff --git a/src/Forms/SubmitForm.php b/src/Forms/SubmitForm.php index 27fc1f443ca..e191533c5e6 100644 --- a/src/Forms/SubmitForm.php +++ b/src/Forms/SubmitForm.php @@ -85,11 +85,16 @@ public function submit(array $data, array $files = []): SubmissionResult } if ($this->shouldFinalize($nextPage)) { - throw_if(Arr::get($values, $this->form->honeypot()), new SilentFormFailureException); + if (Arr::get($values, $this->form->honeypot())) { + $this->rejectSpamSubmission(); + } + throw_if(FormSubmitted::dispatch($this->submission) === false, new SilentFormFailureException); } } catch (ValidationException|SilentFormFailureException $e) { - $this->removeUploadedAssets($uploadedAssets); + if (! $this->submission?->isSpam()) { + $this->removeUploadedAssets($uploadedAssets); + } throw $e; } @@ -175,6 +180,18 @@ private function fieldHandles(string $page): array ->all(); } + private function rejectSpamSubmission(): void + { + if ( + $this->form->get('honeypot_behavior', 'ignore') === 'mark_as_spam' + && $this->form->store() + ) { + $this->submission->markAsSpam()->save(); + } + + throw new SilentFormFailureException; + } + /** * Remove any uploaded assets. * diff --git a/src/Http/Controllers/CP/Forms/FormsController.php b/src/Http/Controllers/CP/Forms/FormsController.php index 2880654919d..ea738feb668 100644 --- a/src/Http/Controllers/CP/Forms/FormsController.php +++ b/src/Http/Controllers/CP/Forms/FormsController.php @@ -43,7 +43,7 @@ public function index(Request $request) 'id' => $form->handle(), 'title' => __($form->title()), 'status' => $form->status(), - 'submissions' => $canViewSubmissions ? $form->querySubmissions()->whereNull('partial')->count() : null, + 'submissions' => $canViewSubmissions ? $form->querySubmissions()->whereNull('partial')->whereNull('spam')->count() : null, 'show_url' => $form->showUrl(), 'submissions_url' => $form->submissionsUrl(), 'edit_url' => $form->editUrl(), @@ -195,13 +195,23 @@ protected function editFormBlueprint($form) ], ], ], - 'fields' => [ - 'display' => __('Fields'), + 'honeypot' => [ + 'display' => __('Honeypot'), 'fields' => [ 'honeypot' => [ 'type' => 'text', 'instructions' => __('statamic::messages.form_configure_honeypot_instructions'), ], + 'honeypot_behavior' => [ + 'display' => __('Honeypot Behavior'), + 'type' => 'button_group', + 'default' => 'ignore', + 'options' => [ + 'ignore' => __('Ignore'), + 'mark_as_spam' => __('Save as Spam'), + ], + 'instructions' => __('statamic::messages.form_configure_honeypot_behavior_instructions'), + ], ], ], 'submissions' => [ diff --git a/src/Http/Controllers/FormController.php b/src/Http/Controllers/FormController.php index 975290875f6..a163b9ce3bf 100644 --- a/src/Http/Controllers/FormController.php +++ b/src/Http/Controllers/FormController.php @@ -67,6 +67,10 @@ public function submit(Request $request, $form, SubmitForm $action) } catch (SilentFormFailureException $e) { $result = new SubmissionResult(submission: $action->submission()); + if ($result->submission->isSpam()) { + $this->forgetPartialSubmission($form); + } + return $this->formSuccess($params, $result, silentFailure: true); } catch (ValidationException $e) { return $this->formFailure($params, $e->errors(), $form->handle()); diff --git a/tests/Feature/Forms/EditFormTest.php b/tests/Feature/Forms/EditFormTest.php index dac8ecaf928..11d59fe8059 100644 --- a/tests/Feature/Forms/EditFormTest.php +++ b/tests/Feature/Forms/EditFormTest.php @@ -115,9 +115,9 @@ public function fields_can_be_added() $user = User::make()->assignRole('test')->save(); $form = tap(Form::make('test'))->save(); - Form::appendConfigFields('*', 'Fields', [ - 'a' => ['type' => 'text', 'display' => 'First injected into fields section'], - 'b' => ['type' => 'text', 'display' => 'Second injected into fields section'], + Form::appendConfigFields('*', 'Honeypot', [ + 'a' => ['type' => 'text', 'display' => 'First injected into honeypot section'], + 'b' => ['type' => 'text', 'display' => 'Second injected into honeypot section'], ]); Form::appendConfigFields('*', 'Additional Section', [ 'c' => ['type' => 'text', 'display' => 'First injected into additional section'], @@ -131,8 +131,8 @@ public function fields_can_be_added() ->assertSeeInOrder([ 'Title', 'Honeypot', - 'First injected into fields section', - 'Second injected into fields section', + 'First injected into honeypot section', + 'Second injected into honeypot section', 'Store Submissions', 'Additional Section', 'First injected into additional section', diff --git a/tests/Forms/SubmitFormTest.php b/tests/Forms/SubmitFormTest.php index 033f7745d8e..0b8fc7fe6b5 100644 --- a/tests/Forms/SubmitFormTest.php +++ b/tests/Forms/SubmitFormTest.php @@ -261,6 +261,85 @@ public function it_throws_silent_failure_exception_when_honeypot_is_filled() ); } + #[Test] + public function it_saves_the_submission_as_spam_when_the_honeypot_is_filled_and_configured_to_do_so() + { + Bus::fake(); + Event::fake([FormSubmitted::class, SubmissionFinalized::class]); + + $this->form->data(['honeypot_behavior' => 'mark_as_spam'])->save(); + + try { + $this->action()->submit(['email' => 'test@example.com', 'winnie' => 'the pooh']); + + $this->fail('Expected SilentFormFailureException was not thrown'); + } catch (SilentFormFailureException $e) { + // Expected + } + + $submission = $this->form->submissions()->first(); + + $this->assertNotNull($submission); + $this->assertTrue($submission->isSpam()); + $this->assertEquals('spam', $submission->status()); + + // The raw partial key sticks around so finalizing can pick up where it + // left off when the submission is marked as not spam, but the + // submission no longer reports itself as partial. + $this->assertTrue($submission->get('partial')); + $this->assertFalse($submission->isPartial()); + + Event::assertNotDispatched(FormSubmitted::class); + Event::assertNotDispatched(SubmissionFinalized::class); + Bus::assertNotDispatched(SendEmails::class); + } + + #[Test] + public function it_ignores_the_honeypot_spam_setting_when_the_form_does_not_store_submissions() + { + $this->form->store(false)->data(['honeypot_behavior' => 'mark_as_spam'])->save(); + + try { + $this->action()->submit(['email' => 'test@example.com', 'winnie' => 'the pooh']); + + $this->fail('Expected SilentFormFailureException was not thrown'); + } catch (SilentFormFailureException $e) { + // Expected + } + + $this->assertCount(0, $this->form->submissions()); + } + + #[Test] + public function it_keeps_uploaded_files_when_a_spam_submission_is_stored() + { + Bus::fake(); // Otherwise the temp file is deleted by DeleteTemporaryFiles right after submission. + + Storage::fake('avatars'); + AssetContainer::make('avatars')->disk('avatars')->save(); + + $form = $this->uploadForm(honeypot: true); + $form->data(['honeypot_behavior' => 'mark_as_spam'])->save(); + + $action = app(SubmitForm::class)->form($form)->page('main'); + + try { + $action->submit( + data: ['email' => 'test@example.com', 'winnie' => 'the pooh'], + files: ['avatar' => [UploadedFile::fake()->image('avatar.jpg')]], + ); + } catch (SilentFormFailureException $e) { + // Expected + } + + // The file stays as a temporary upload until the submission is + // marked as not spam, at which point finalizing moves it into + // the asset container. + Storage::disk('local')->assertExists('statamic/form-uploads/'.$action->submission()->get('avatar')[0]); + + $form->submissions()->each->delete(); + } + #[Test] public function it_throws_silent_failure_exception_when_event_listener_returns_false() { diff --git a/tests/Tags/Form/FormCreateTest.php b/tests/Tags/Form/FormCreateTest.php index 3ce6e072589..bf8136527e7 100644 --- a/tests/Tags/Form/FormCreateTest.php +++ b/tests/Tags/Form/FormCreateTest.php @@ -1314,6 +1314,51 @@ public function it_will_submit_form_with_honeypot_filled_and_render_fake_success $this->assertStringNotContainsString('
', $output); } + #[Test] + public function it_will_store_the_submission_as_spam_when_the_honeypot_is_filled_and_configured_to_do_so() + { + Form::find('contact')->data(['honeypot_behavior' => 'mark_as_spam'])->save(); + + $this + ->post('/!/forms/contact', [ + 'email' => 'san@holo.com', + 'message' => 'hello', + 'winnie' => 'the pooh', + ]) + ->assertSessionHasNoErrors() + ->assertLocation('/'); + + $submissions = Form::find('contact')->submissions(); + + $this->assertCount(1, $submissions); + $this->assertEquals('spam', $submissions->first()->status()); + } + + #[Test] + public function it_forgets_the_partial_submission_when_it_is_stored_as_spam() + { + $this->createMultiPageForm(); + Form::find('survey')->data(['honeypot_behavior' => 'mark_as_spam'])->save(); + + $this + ->post('/!/forms/survey', ['_page' => 'page_one', 'name' => 'Olaf']) + ->assertSessionHas('form.survey.partial_submission'); + + // Tripping the honeypot on the final page stores the submission as spam, and the + // session ends up in the same state as a successful submission would leave it. + $this + ->post('/!/forms/survey', ['_page' => 'page_two', 'email' => 'olaf@example.com', 'winnie' => 'the pooh']) + ->assertSessionHasNoErrors() + ->assertSessionMissing('form.survey.partial_submission'); + + $submissions = Form::find('survey')->submissions(); + + $this->assertCount(1, $submissions); + $this->assertEquals('spam', $submissions->first()->status()); + + Form::find('survey')->submissions()->each->delete(); + } + #[Test] public function it_will_render_fake_success_when_a_listener_throws_a_bare_silent_failure_exception() { From 36ecc63bcf19f3fb44c341d1070e2d8e93fb8248 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 17 Aug 2026 17:39:40 +0100 Subject: [PATCH 3/4] add actions for marking submissions as spam and not spam Co-Authored-By: Claude Fable 5 --- src/Actions/MarkAsNotSpam.php | 53 +++++++++++ src/Actions/MarkAsSpam.php | 45 +++++++++ src/Policies/FormSubmissionPolicy.php | 10 ++ src/Providers/ExtensionServiceProvider.php | 2 + tests/Actions/MarkAsNotSpamTest.php | 105 +++++++++++++++++++++ tests/Actions/MarkAsSpamTest.php | 73 ++++++++++++++ 6 files changed, 288 insertions(+) create mode 100644 src/Actions/MarkAsNotSpam.php create mode 100644 src/Actions/MarkAsSpam.php create mode 100644 tests/Actions/MarkAsNotSpamTest.php create mode 100644 tests/Actions/MarkAsSpamTest.php diff --git a/src/Actions/MarkAsNotSpam.php b/src/Actions/MarkAsNotSpam.php new file mode 100644 index 00000000000..e1155b0a242 --- /dev/null +++ b/src/Actions/MarkAsNotSpam.php @@ -0,0 +1,53 @@ +isSpam(); + } + + public function authorize($user, $item) + { + return $user->can('markAsNotSpam', $item); + } + + public function buttonText() + { + /** @translation */ + return 'Mark as Not Spam|Mark :count Submissions as Not Spam'; + } + + public function confirmationText() + { + /** @translation */ + return 'statamic::messages.mark_as_not_spam_action_confirmation'; + } + + public function run($items, $values) + { + $items->each(function ($submission) { + $submission->markAsNotSpam(); + + $submission->isPartial() ? $submission->finalize() : $submission->save(); + }); + + return [ + 'message' => trans_choice('Submission marked as not spam|Submissions marked as not spam', $items->count()), + ]; + } +} diff --git a/src/Actions/MarkAsSpam.php b/src/Actions/MarkAsSpam.php new file mode 100644 index 00000000000..400bfe59672 --- /dev/null +++ b/src/Actions/MarkAsSpam.php @@ -0,0 +1,45 @@ +isSpam(); + } + + public function authorize($user, $item) + { + return $user->can('markAsSpam', $item); + } + + public function buttonText() + { + /** @translation */ + return 'Mark as Spam|Mark :count Submissions as Spam'; + } + + public function run($items, $values) + { + $items->each(fn ($submission) => $submission->markAsSpam()->save()); + + return [ + 'message' => trans_choice('Submission marked as spam|Submissions marked as spam', $items->count()), + ]; + } +} diff --git a/src/Policies/FormSubmissionPolicy.php b/src/Policies/FormSubmissionPolicy.php index d23fb334eaf..d5314f4858e 100644 --- a/src/Policies/FormSubmissionPolicy.php +++ b/src/Policies/FormSubmissionPolicy.php @@ -15,4 +15,14 @@ public function delete($user, $submission) { return User::fromUser($user)->can('deleteSubmissions', $submission->form()); } + + public function markAsSpam($user, $submission) + { + return User::fromUser($user)->can('viewSubmissions', $submission->form()); + } + + public function markAsNotSpam($user, $submission) + { + return User::fromUser($user)->can('viewSubmissions', $submission->form()); + } } diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php index d0f524d1433..05260050b5c 100644 --- a/src/Providers/ExtensionServiceProvider.php +++ b/src/Providers/ExtensionServiceProvider.php @@ -41,6 +41,8 @@ class ExtensionServiceProvider extends ServiceProvider Actions\DuplicateEntry::class, Actions\DuplicateForm::class, Actions\DuplicateTerm::class, + Actions\MarkAsSpam::class, + Actions\MarkAsNotSpam::class, Actions\Publish::class, Actions\Unpublish::class, Actions\SendPasswordReset::class, diff --git a/tests/Actions/MarkAsNotSpamTest.php b/tests/Actions/MarkAsNotSpamTest.php new file mode 100644 index 00000000000..bd5e060ff2e --- /dev/null +++ b/tests/Actions/MarkAsNotSpamTest.php @@ -0,0 +1,105 @@ +form = tap(Form::make('contact'))->save(); + } + + public function tearDown(): void + { + $this->form->submissions()->each->delete(); + + parent::tearDown(); + } + + #[Test] + public function it_finalizes_submissions_that_were_caught_before_being_finalized() + { + Bus::fake(); + Event::fake([SubmissionFinalized::class]); + + $submission = tap($this->form->makeSubmission()->asPartial()->markAsSpam()->data(['name' => 'Olaf']))->save(); + + (new MarkAsNotSpam)->run(collect([$submission]), []); + + $submission = $this->form->submission($submission->id()); + + $this->assertFalse($submission->isSpam()); + $this->assertFalse($submission->isPartial()); + $this->assertEquals('finalized', $submission->status()); + + Event::assertDispatched(SubmissionFinalized::class); + Bus::assertDispatched(CreateAssetsFromFileUploads::class); + Bus::assertDispatched(SendEmails::class); + } + + #[Test] + public function it_does_not_refinalize_submissions_that_were_flagged_after_being_finalized() + { + Bus::fake(); + Event::fake([SubmissionFinalized::class]); + + $submission = tap($this->form->makeSubmission()->markAsSpam()->data(['name' => 'Olaf']))->save(); + + (new MarkAsNotSpam)->run(collect([$submission]), []); + + $submission = $this->form->submission($submission->id()); + + $this->assertFalse($submission->isSpam()); + $this->assertEquals('finalized', $submission->status()); + + Event::assertNotDispatched(SubmissionFinalized::class); + Bus::assertNotDispatched(SendEmails::class); + } + + #[Test] + public function it_requires_permission_to_view_submissions() + { + $this->setTestRoles([ + 'access' => ['view form submissions'], + 'noaccess' => [], + ]); + + $userWithPermission = tap(User::make()->assignRole('access'))->save(); + $userWithoutPermission = tap(User::make()->assignRole('noaccess'))->save(); + $submission = tap($this->form->makeSubmission()->markAsSpam()->data(['name' => 'Olaf']))->save(); + + $this->assertTrue((new MarkAsNotSpam)->authorize($userWithPermission, $submission)); + $this->assertFalse((new MarkAsNotSpam)->authorize($userWithoutPermission, $submission)); + } + + #[Test] + public function it_is_only_visible_to_spam_submissions() + { + $submission = $this->form->makeSubmission(); + $spam = $this->form->makeSubmission()->markAsSpam(); + + $this->assertTrue((new MarkAsNotSpam)->visibleTo($spam)); + $this->assertFalse((new MarkAsNotSpam)->visibleTo($submission)); + $this->assertFalse((new MarkAsNotSpam)->visibleTo($this->form)); + } +} diff --git a/tests/Actions/MarkAsSpamTest.php b/tests/Actions/MarkAsSpamTest.php new file mode 100644 index 00000000000..ce44b74169c --- /dev/null +++ b/tests/Actions/MarkAsSpamTest.php @@ -0,0 +1,73 @@ +form = tap(Form::make('contact'))->save(); + } + + public function tearDown(): void + { + $this->form->submissions()->each->delete(); + + parent::tearDown(); + } + + #[Test] + public function it_marks_submissions_as_spam() + { + $submission = tap($this->form->makeSubmission()->data(['name' => 'Olaf']))->save(); + + (new MarkAsSpam)->run(collect([$submission]), []); + + $submission = $this->form->submission($submission->id()); + + $this->assertTrue($submission->isSpam()); + $this->assertEquals('spam', $submission->status()); + } + + #[Test] + public function it_is_only_visible_to_submissions_that_are_not_spam() + { + $submission = $this->form->makeSubmission(); + $spam = $this->form->makeSubmission()->markAsSpam(); + + $this->assertTrue((new MarkAsSpam)->visibleTo($submission)); + $this->assertFalse((new MarkAsSpam)->visibleTo($spam)); + $this->assertFalse((new MarkAsSpam)->visibleTo($this->form)); + } + + #[Test] + public function it_requires_permission_to_view_submissions() + { + $this->setTestRoles([ + 'access' => ['view form submissions'], + 'noaccess' => [], + ]); + + $userWithPermission = tap(User::make()->assignRole('access'))->save(); + $userWithoutPermission = tap(User::make()->assignRole('noaccess'))->save(); + $submission = tap($this->form->makeSubmission()->data(['name' => 'Olaf']))->save(); + + $this->assertTrue((new MarkAsSpam)->authorize($userWithPermission, $submission)); + $this->assertFalse((new MarkAsSpam)->authorize($userWithoutPermission, $submission)); + } +} From d63930d6f51ecb5347408fc484b42a7bfd9c307b Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 17 Aug 2026 17:39:40 +0100 Subject: [PATCH 4/4] skip missing files when attaching them to form emails Co-Authored-By: Claude Fable 5 --- src/Forms/Email.php | 5 ++++- tests/Forms/EmailTest.php | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/Forms/Email.php b/src/Forms/Email.php index be52427b0ba..0400c0abd28 100644 --- a/src/Forms/Email.php +++ b/src/Forms/Email.php @@ -5,6 +5,7 @@ use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Storage; use Statamic\Contracts\Forms\Submission; use Statamic\Facades\Antlers; use Statamic\Facades\Blueprint; @@ -161,7 +162,9 @@ private function attachFiles($field) : config('statamic.system.file_uploads_path', 'statamic/file-uploads'); foreach ($value as $file) { - $this->attachFromStorageDisk($disk, $basePath.'/'.$file); + if (Storage::disk($disk)->exists($path = "{$basePath}/{$file}")) { + $this->attachFromStorageDisk($disk, $path); + } } } diff --git a/tests/Forms/EmailTest.php b/tests/Forms/EmailTest.php index c9d40b82f35..15d73a7a513 100644 --- a/tests/Forms/EmailTest.php +++ b/tests/Forms/EmailTest.php @@ -351,6 +351,24 @@ public function it_attaches_files_from_files_field() $this->assertTrue($email->hasAttachmentFromStorageDisk('local', 'statamic/file-uploads/'.$documentPath)); } + #[Test] + public function it_skips_attachments_whose_temporary_files_no_longer_exist() + { + Storage::fake('local'); + + $form = tap(Form::make('test')->formFields([ + 'fields' => [ + ['handle' => 'document', 'field' => ['type' => 'files', 'max_files' => 1]], + ], + ]))->save(); + + $submission = $form->makeSubmission()->data(['document' => now()->timestamp.'/resume.pdf']); + + $email = tap(new Email($submission, ['to' => 'test@test.com', 'attachments' => true], Site::default()))->build(); + + $this->assertEmpty($email->attachments); + } + #[Test] public function it_attaches_files_from_files_field_on_the_configured_disk_and_path() {