From dad5c825b112e0c9676d07a78190c8d995be4cd4 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala <60499540+chiragchhatrala@users.noreply.github.com> Date: Tue, 22 Oct 2024 14:04:29 +0530 Subject: [PATCH] Apply Mentions everywhere (#595) * variables and mentions * fix lint * add missing changes * fix tests * update quilly, fix bugs * fix lint * apply fixes * apply fixes * Fix MentionParser * Apply Mentions everywhere * Fix MentionParserTest * Small refactoring * Fixing quill import issues * Polished email integration, added customer sender mail * Add missing changes * improve migration command --------- Co-authored-by: Frank Co-authored-by: Julien Nahum --- .../Commands/EmailNotificationMigration.php | 108 ++++++++++ .../Forms/PublicFormController.php | 24 ++- api/app/Http/Requests/UserFormRequest.php | 2 +- .../Handlers/DiscordIntegration.php | 10 +- .../Handlers/EmailIntegration.php | 53 ++++- .../Handlers/SlackIntegration.php | 10 +- .../SubmissionConfirmationIntegration.php | 113 ---------- .../Mail/Forms/SubmissionConfirmationMail.php | 75 ------- .../Forms/FormEmailNotification.php | 188 +++++++++++++++++ .../Forms/FormSubmissionNotification.php | 113 ---------- api/app/Open/MentionParser.php | 97 +++++++++ .../HtmlPurifier/OpenFormsHtmlDefinition.php | 21 ++ api/config/purify.php | 6 +- api/resources/data/forms/integrations.json | 7 - ...irmation-submission-notification.blade.php | 34 --- .../mail/form/email-notification.blade.php | 22 ++ .../form/submission-notification.blade.php | 28 --- .../Feature/Forms/ConfirmationEmailTest.php | 144 ------------- api/tests/Feature/Forms/CustomSmtpTest.php | 39 ++-- .../Feature/Forms/EmailNotificationTest.php | 165 +++++++++++++++ .../Forms/FormIntegrationEventTest.php | 9 +- .../Feature/Forms/FormIntegrationTest.php | 9 +- .../Unit/EmailNotificationMigrationTest.php | 80 +++++++ .../Unit/Service/Forms/MentionParserTest.php | 86 ++++++++ client/components/forms/MentionInput.vue | 199 ++++++++++++++++++ .../forms/RichTextAreaInput.client.vue | 157 +++++++++----- client/components/forms/TextBlock.vue | 29 +++ .../components/FormSubmissionFormatter.js | 105 +++++++++ .../forms/components/MentionDropdown.vue | 116 ++++++++++ .../forms/components/QuillyEditor.vue | 98 +++++++++ client/components/global/Modal.vue | 4 +- .../open/forms/OpenCompleteForm.vue | 10 +- .../forms/components/FirstSubmissionModal.vue | 2 +- .../FormSubmissionSettings.vue | 5 +- .../open/integrations/DiscordIntegration.vue | 5 +- .../open/integrations/EmailIntegration.vue | 89 ++++++-- .../open/integrations/SlackIntegration.vue | 5 +- .../SubmissionConfirmationIntegration.vue | 108 ---------- .../components/IntegrationWrapper.vue | 21 +- .../NotificationsMessageActions.vue | 16 +- .../pages/pricing/SubscriptionModal.vue | 1 + client/composables/lib/vForm/Form.js | 2 +- client/composables/useParseMention.js | 39 ++++ client/data/blocks_types.json | 60 ++++-- client/data/forms/integrations.json | 7 - client/lib/quill/quillMentionExtension.js | 130 ++++++++++++ client/lib/utils.js | 2 +- client/nuxt.config.ts | 3 - client/package-lock.json | 113 ++++------ client/package.json | 4 +- 50 files changed, 1901 insertions(+), 872 deletions(-) create mode 100644 api/app/Console/Commands/EmailNotificationMigration.php delete mode 100644 api/app/Integrations/Handlers/SubmissionConfirmationIntegration.php delete mode 100644 api/app/Mail/Forms/SubmissionConfirmationMail.php create mode 100644 api/app/Notifications/Forms/FormEmailNotification.php delete mode 100644 api/app/Notifications/Forms/FormSubmissionNotification.php create mode 100644 api/app/Open/MentionParser.php create mode 100644 api/app/Service/HtmlPurifier/OpenFormsHtmlDefinition.php delete mode 100644 api/resources/views/mail/form/confirmation-submission-notification.blade.php create mode 100644 api/resources/views/mail/form/email-notification.blade.php delete mode 100644 api/resources/views/mail/form/submission-notification.blade.php delete mode 100644 api/tests/Feature/Forms/ConfirmationEmailTest.php create mode 100644 api/tests/Feature/Forms/EmailNotificationTest.php create mode 100644 api/tests/Unit/EmailNotificationMigrationTest.php create mode 100644 api/tests/Unit/Service/Forms/MentionParserTest.php create mode 100644 client/components/forms/MentionInput.vue create mode 100644 client/components/forms/TextBlock.vue create mode 100644 client/components/forms/components/FormSubmissionFormatter.js create mode 100644 client/components/forms/components/MentionDropdown.vue create mode 100644 client/components/forms/components/QuillyEditor.vue delete mode 100644 client/components/open/integrations/SubmissionConfirmationIntegration.vue create mode 100644 client/composables/useParseMention.js create mode 100644 client/lib/quill/quillMentionExtension.js diff --git a/api/app/Console/Commands/EmailNotificationMigration.php b/api/app/Console/Commands/EmailNotificationMigration.php new file mode 100644 index 000000000..15230ebd5 --- /dev/null +++ b/api/app/Console/Commands/EmailNotificationMigration.php @@ -0,0 +1,108 @@ +environment('production')) { + if (!$this->confirm('Are you sure you want to run this migration in production?')) { + $this->info('Migration aborted.'); + return 0; + } + } + $query = FormIntegration::whereIn('integration_id', ['email', 'submission_confirmation']) + ->whereHas('form'); + $totalCount = $query->count(); + $progressBar = $this->output->createProgressBar($totalCount); + $progressBar->start(); + + $query->with('form')->chunk(100, function ($integrations) use ($progressBar) { + foreach ($integrations as $integration) { + try { + $this->updateIntegration($integration); + } catch (\Exception $e) { + $this->error('Error updating integration ' . $integration->id . '. Error: ' . $e->getMessage()); + ray($e); + } + $progressBar->advance(); + } + }); + + $progressBar->finish(); + $this->newLine(); + + $this->line('Migration Done'); + } + + public function updateIntegration(FormIntegration $integration) + { + if (!$integration->form) { + return; + } + $existingData = $integration->data; + if ($integration->integration_id === 'email') { + $integration->data = [ + 'send_to' => $existingData->notification_emails ?? null, + 'sender_name' => 'OpnForm', + 'subject' => 'New form submission', + 'email_content' => 'Hello there πŸ‘‹
New form submission received.', + 'include_submission_data' => true, + 'include_hidden_fields_submission_data' => false, + 'reply_to' => $existingData->notification_reply_to ?? null + ]; + } elseif ($integration->integration_id === 'submission_confirmation') { + $integration->integration_id = 'email'; + $integration->data = [ + 'send_to' => $this->getMentionHtml($integration->form), + 'sender_name' => $existingData->notification_sender, + 'subject' => $existingData->notification_subject, + 'email_content' => $existingData->notification_body, + 'include_submission_data' => $existingData->notifications_include_submission, + 'include_hidden_fields_submission_data' => false, + 'reply_to' => $existingData->confirmation_reply_to ?? null + ]; + } + return $integration->save(); + } + + private function getMentionHtml(Form $form) + { + $emailField = $this->getRespondentEmail($form); + return $emailField ? '' . $emailField['name'] . '' : ''; + } + + private function getRespondentEmail(Form $form) + { + $emailFields = collect($form->properties)->filter(function ($field) { + $hidden = $field['hidden'] ?? false; + return !$hidden && $field['type'] == 'email'; + }); + + return $emailFields->count() > 0 ? $emailFields->first() : null; + } +} diff --git a/api/app/Http/Controllers/Forms/PublicFormController.php b/api/app/Http/Controllers/Forms/PublicFormController.php index 3e0c656d7..c8b2254ee 100644 --- a/api/app/Http/Controllers/Forms/PublicFormController.php +++ b/api/app/Http/Controllers/Forms/PublicFormController.php @@ -9,6 +9,7 @@ use App\Jobs\Form\StoreFormSubmissionJob; use App\Models\Forms\Form; use App\Models\Forms\FormSubmission; +use App\Open\MentionParser; use App\Service\Forms\FormCleaner; use App\Service\WorkspaceHelper; use Illuminate\Http\Request; @@ -105,13 +106,28 @@ public function answer(AnswerFormRequest $request) return $this->success(array_merge([ 'message' => 'Form submission saved.', 'submission_id' => $submissionId, - 'is_first_submission' => $isFirstSubmission - ], $request->form->is_pro && $request->form->redirect_url ? [ + 'is_first_submission' => $isFirstSubmission, + ], $this->getRedirectData($request->form, $submissionData))); + } + + private function getRedirectData($form, $submissionData) + { + $formattedData = collect($submissionData)->map(function ($value, $key) { + return ['id' => $key, 'value' => $value]; + })->values()->all(); + + $redirectUrl = ($form->redirect_url) ? (new MentionParser($form->redirect_url, $formattedData))->parse() : null; + + if ($redirectUrl && !filter_var($redirectUrl, FILTER_VALIDATE_URL)) { + $redirectUrl = null; + } + + return $form->is_pro && $redirectUrl ? [ 'redirect' => true, - 'redirect_url' => $request->form->redirect_url, + 'redirect_url' => $redirectUrl, ] : [ 'redirect' => false, - ])); + ]; } public function fetchSubmission(Request $request, string $slug, string $submissionId) diff --git a/api/app/Http/Requests/UserFormRequest.php b/api/app/Http/Requests/UserFormRequest.php index 8c20eeda2..b79bd8eba 100644 --- a/api/app/Http/Requests/UserFormRequest.php +++ b/api/app/Http/Requests/UserFormRequest.php @@ -53,7 +53,7 @@ public function rules() 're_fillable' => 'boolean', 're_fill_button_text' => 'string|min:1|max:50', 'submitted_text' => 'string|max:2000', - 'redirect_url' => 'nullable|active_url|max:255', + 'redirect_url' => 'nullable|max:255', 'database_fields_update' => 'nullable|array', 'max_submissions_count' => 'integer|nullable|min:1', 'max_submissions_reached_text' => 'string|nullable', diff --git a/api/app/Integrations/Handlers/DiscordIntegration.php b/api/app/Integrations/Handlers/DiscordIntegration.php index 94d9272fb..1a3fbaf1a 100644 --- a/api/app/Integrations/Handlers/DiscordIntegration.php +++ b/api/app/Integrations/Handlers/DiscordIntegration.php @@ -2,6 +2,7 @@ namespace App\Integrations\Handlers; +use App\Open\MentionParser; use App\Service\Forms\FormSubmissionFormatter; use Illuminate\Support\Arr; use Vinkla\Hashids\Facades\Hashids; @@ -32,6 +33,9 @@ protected function shouldRun(): bool protected function getWebhookData(): array { + $formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))->outputStringsOnly(); + $formattedData = $formatter->getFieldsWithValue(); + $settings = (array) $this->integrationData ?? []; $externalLinks = []; if (Arr::get($settings, 'link_open_form', true)) { @@ -50,8 +54,7 @@ protected function getWebhookData(): array $blocks = []; if (Arr::get($settings, 'include_submission_data', true)) { $submissionString = ''; - $formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))->outputStringsOnly(); - foreach ($formatter->getFieldsWithValue() as $field) { + foreach ($formattedData as $field) { $tmpVal = is_array($field['value']) ? implode(',', $field['value']) : $field['value']; $submissionString .= '**' . ucfirst($field['name']) . '**: ' . $tmpVal . "\n"; } @@ -80,8 +83,9 @@ protected function getWebhookData(): array ]; } + $message = Arr::get($settings, 'message', 'New form submission'); return [ - 'content' => 'New submission for your form **' . $this->form->title . '**', + 'content' => (new MentionParser($message, $formattedData))->parse(), 'tts' => false, 'username' => config('app.name'), 'avatar_url' => asset('img/logo.png'), diff --git a/api/app/Integrations/Handlers/EmailIntegration.php b/api/app/Integrations/Handlers/EmailIntegration.php index b4fcd08c3..d425c3456 100644 --- a/api/app/Integrations/Handlers/EmailIntegration.php +++ b/api/app/Integrations/Handlers/EmailIntegration.php @@ -2,24 +2,51 @@ namespace App\Integrations\Handlers; -use App\Rules\OneEmailPerLine; +use App\Notifications\Forms\FormEmailNotification; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Notification; -use App\Notifications\Forms\FormSubmissionNotification; +use App\Open\MentionParser; +use App\Service\Forms\FormSubmissionFormatter; class EmailIntegration extends AbstractEmailIntegrationHandler { + public const RISKY_USERS_LIMIT = 120; + public static function getValidationRules(): array { return [ - 'notification_emails' => ['required', new OneEmailPerLine()], - 'notification_reply_to' => 'email|nullable', + 'send_to' => 'required', + 'sender_name' => 'required', + 'sender_email' => 'email|nullable', + 'subject' => 'required', + 'email_content' => 'required', + 'include_submission_data' => 'boolean', + 'include_hidden_fields_submission_data' => ['nullable', 'boolean'], + 'reply_to' => 'nullable', ]; } protected function shouldRun(): bool { - return $this->integrationData->notification_emails && parent::shouldRun(); + return $this->integrationData->send_to && parent::shouldRun() && !$this->riskLimitReached(); + } + + // To avoid phishing abuse we limit this feature for risky users + private function riskLimitReached(): bool + { + // This is a per-workspace limit for risky workspaces + if ($this->form->workspace->is_risky) { + if ($this->form->workspace->submissions_count >= self::RISKY_USERS_LIMIT) { + Log::error('!!!DANGER!!! Dangerous user detected! Attempting many email sending.', [ + 'form_id' => $this->form->id, + 'workspace_id' => $this->form->workspace->id, + ]); + + return true; + } + } + + return false; } public function handle(): void @@ -28,19 +55,27 @@ public function handle(): void return; } - $subscribers = collect(preg_split("/\r\n|\n|\r/", $this->integrationData->notification_emails)) + if ($this->form->is_pro) { // For Send to field Mentions are Pro feature + $formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))->outputStringsOnly(); + $parser = new MentionParser($this->integrationData->send_to, $formatter->getFieldsWithValue()); + $sendTo = $parser->parse(); + } else { + $sendTo = $this->integrationData->send_to; + } + + $recipients = collect(preg_split("/\r\n|\n|\r/", $sendTo)) ->filter(function ($email) { return filter_var($email, FILTER_VALIDATE_EMAIL); }); Log::debug('Sending email notification', [ - 'recipients' => $subscribers->toArray(), + 'recipients' => $recipients->toArray(), 'form_id' => $this->form->id, 'form_slug' => $this->form->slug, 'mailer' => $this->mailer ]); - $subscribers->each(function ($subscriber) { + $recipients->each(function ($subscriber) { Notification::route('mail', $subscriber)->notify( - new FormSubmissionNotification($this->event, $this->integrationData, $this->mailer) + new FormEmailNotification($this->event, $this->integrationData, $this->mailer) ); }); } diff --git a/api/app/Integrations/Handlers/SlackIntegration.php b/api/app/Integrations/Handlers/SlackIntegration.php index f0673f23b..c34b664fa 100644 --- a/api/app/Integrations/Handlers/SlackIntegration.php +++ b/api/app/Integrations/Handlers/SlackIntegration.php @@ -2,6 +2,7 @@ namespace App\Integrations\Handlers; +use App\Open\MentionParser; use App\Service\Forms\FormSubmissionFormatter; use Illuminate\Support\Arr; use Vinkla\Hashids\Facades\Hashids; @@ -32,6 +33,9 @@ protected function shouldRun(): bool protected function getWebhookData(): array { + $formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))->outputStringsOnly(); + $formattedData = $formatter->getFieldsWithValue(); + $settings = (array) $this->integrationData ?? []; $externalLinks = []; if (Arr::get($settings, 'link_open_form', true)) { @@ -46,20 +50,20 @@ protected function getWebhookData(): array $externalLinks[] = '*<' . $this->form->share_url . '?submission_id=' . $submissionId . '|✍️ ' . $this->form->editable_submissions_button_text . '>*'; } + $message = Arr::get($settings, 'message', 'New form submission'); $blocks = [ [ 'type' => 'section', 'text' => [ 'type' => 'mrkdwn', - 'text' => 'New submission for your form *' . $this->form->title . '*', + 'text' => (new MentionParser($message, $formattedData))->parse(), ], ], ]; if (Arr::get($settings, 'include_submission_data', true)) { $submissionString = ''; - $formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))->outputStringsOnly(); - foreach ($formatter->getFieldsWithValue() as $field) { + foreach ($formattedData as $field) { $tmpVal = is_array($field['value']) ? implode(',', $field['value']) : $field['value']; $submissionString .= '>*' . ucfirst($field['name']) . '*: ' . $tmpVal . " \n"; } diff --git a/api/app/Integrations/Handlers/SubmissionConfirmationIntegration.php b/api/app/Integrations/Handlers/SubmissionConfirmationIntegration.php deleted file mode 100644 index 8b40fe4a6..000000000 --- a/api/app/Integrations/Handlers/SubmissionConfirmationIntegration.php +++ /dev/null @@ -1,113 +0,0 @@ - [ - 'required', - 'boolean', - function ($attribute, $value, $fail) { - if ($value !== true) { - $fail('Need at least 1 email field.'); - } - }, - ], - 'confirmation_reply_to' => 'email|nullable', - 'notification_sender' => 'required', - 'notification_subject' => 'required', - 'notification_body' => 'required', - 'notifications_include_submission' => 'boolean' - ]; - } - - protected function shouldRun(): bool - { - return !(!$this->form->is_pro) && parent::shouldRun() && !$this->riskLimitReached(); - } - - public function handle(): void - { - if (!$this->shouldRun()) { - return; - } - - $email = $this->getRespondentEmail(); - if (!$email) { - return; - } - - Log::info('Sending submission confirmation', [ - 'recipient' => $email, - 'form_id' => $this->form->id, - 'form_slug' => $this->form->slug, - 'mailer' => $this->mailer - ]); - Mail::mailer($this->mailer)->to($email)->send(new SubmissionConfirmationMail($this->event, $this->integrationData)); - } - - private function getRespondentEmail() - { - // Make sure we only have one email field in the form - $emailFields = collect($this->form->properties)->filter(function ($field) { - $hidden = $field['hidden'] ?? false; - - return !$hidden && $field['type'] == 'email'; - }); - if ($emailFields->count() != 1) { - return null; - } - - if (isset($this->submissionData[$emailFields->first()['id']])) { - $email = $this->submissionData[$emailFields->first()['id']]; - if ($this->validateEmail($email)) { - return $email; - } - } - - return null; - } - - // To avoid phishing abuse we limit this feature for risky users - private function riskLimitReached(): bool - { - // This is a per-workspace limit for risky workspaces - if ($this->form->workspace->is_risky) { - if ($this->form->workspace->submissions_count >= self::RISKY_USERS_LIMIT) { - Log::error('!!!DANGER!!! Dangerous user detected! Attempting many email sending.', [ - 'form_id' => $this->form->id, - 'workspace_id' => $this->form->workspace->id, - ]); - - return true; - } - } - - return false; - } - - public static function validateEmail($email): bool - { - return (bool)filter_var($email, FILTER_VALIDATE_EMAIL); - } - - public static function formatData(array $data): array - { - return array_merge(parent::formatData($data), [ - 'notification_body' => Purify::clean($data['notification_body'] ?? ''), - ]); - } -} diff --git a/api/app/Mail/Forms/SubmissionConfirmationMail.php b/api/app/Mail/Forms/SubmissionConfirmationMail.php deleted file mode 100644 index b50bb9c1e..000000000 --- a/api/app/Mail/Forms/SubmissionConfirmationMail.php +++ /dev/null @@ -1,75 +0,0 @@ -event->form; - - $formatter = (new FormSubmissionFormatter($form, $this->event->data)) - ->createLinks() - ->outputStringsOnly() - ->useSignedUrlForFiles(); - - return $this - ->replyTo($this->getReplyToEmail($form->creator->email)) - ->from($this->getFromEmail(), $this->integrationData->notification_sender) - ->subject($this->integrationData->notification_subject) - ->markdown('mail.form.confirmation-submission-notification', [ - 'fields' => $formatter->getFieldsWithValue(), - 'form' => $form, - 'integrationData' => $this->integrationData, - 'noBranding' => $form->no_branding, - 'submission_id' => (isset($this->event->data['submission_id']) && $this->event->data['submission_id']) ? Hashids::encode($this->event->data['submission_id']) : null, - ]); - } - - private function getFromEmail() - { - if (config('app.self_hosted')) { - return config('mail.from.address'); - } - - $originalFromAddress = Str::of(config('mail.from.address'))->explode('@'); - - return $originalFromAddress->first() . '+' . time() . '@' . $originalFromAddress->last(); - } - - private function getReplyToEmail($default) - { - $replyTo = $this->integrationData->confirmation_reply_to ?? null; - - if ($replyTo && filter_var($replyTo, FILTER_VALIDATE_EMAIL)) { - return $replyTo; - } - return $default; - } -} diff --git a/api/app/Notifications/Forms/FormEmailNotification.php b/api/app/Notifications/Forms/FormEmailNotification.php new file mode 100644 index 000000000..529aae8f5 --- /dev/null +++ b/api/app/Notifications/Forms/FormEmailNotification.php @@ -0,0 +1,188 @@ +event = $event; + $this->mailer = $mailer; + $this->formattedData = $this->formatSubmissionData(); + } + + /** + * Get the notification's delivery channels. + * + * @param mixed $notifiable + * @return array + */ + public function via($notifiable) + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + * + * @param mixed $notifiable + * @return \Illuminate\Notifications\Messages\MailMessage + */ + public function toMail($notifiable) + { + return (new MailMessage()) + ->mailer($this->mailer) + ->replyTo($this->getReplyToEmail($notifiable->routes['mail'])) + ->from($this->getFromEmail(), $this->getSenderName()) + ->subject($this->getSubject()) + ->withSymfonyMessage(function (Email $message) { + $this->addCustomHeaders($message); + }) + ->markdown('mail.form.email-notification', $this->getMailData()); + } + + private function formatSubmissionData(): array + { + $formatter = (new FormSubmissionFormatter($this->event->form, $this->event->data)) + ->createLinks() + ->outputStringsOnly() + ->useSignedUrlForFiles(); + + if ($this->integrationData->include_hidden_fields_submission_data ?? false) { + $formatter->showHiddenFields(); + } + + return $formatter->getFieldsWithValue(); + } + + private function getFromEmail(): string + { + if ( + config('app.self_hosted') + && isset($this->integrationData->sender_email) + && $this->validateEmail($this->integrationData->sender_email) + ) { + return $this->integrationData->sender_email; + } + + return config('mail.from.address'); + } + + private function getSenderName(): string + { + return $this->integrationData->sender_name ?? config('app.name'); + } + + private function getReplyToEmail($default): string + { + $replyTo = $this->integrationData->reply_to ?? null; + + if ($replyTo) { + $parsedReplyTo = $this->parseReplyTo($replyTo); + if ($parsedReplyTo && $this->validateEmail($parsedReplyTo)) { + return $parsedReplyTo; + } + } + + return $this->getRespondentEmail() ?? $default; + } + + private function parseReplyTo(string $replyTo): ?string + { + $parser = new MentionParser($replyTo, $this->formattedData); + return $parser->parse(); + } + + private function getSubject(): string + { + $defaultSubject = 'New form submission'; + $parser = new MentionParser($this->integrationData->subject ?? $defaultSubject, $this->formattedData); + return $parser->parse(); + } + + private function addCustomHeaders(Email $message): void + { + $formId = $this->event->form->id; + $submissionId = $this->event->data['submission_id'] ?? 'unknown'; + $domain = Str::after(config('app.url'), '://'); + + $uniquePart = substr(md5($formId . $submissionId), 0, 8); + $messageId = "form-{$formId}-{$uniquePart}@{$domain}"; + $references = "form-{$formId}@{$domain}"; + + $message->getHeaders()->remove('Message-ID'); + $message->getHeaders()->addIdHeader('Message-ID', $messageId); + $message->getHeaders()->addTextHeader('References', $references); + } + + private function getMailData(): array + { + return [ + 'emailContent' => $this->getEmailContent(), + 'fields' => $this->formattedData, + 'form' => $this->event->form, + 'integrationData' => $this->integrationData, + 'noBranding' => $this->event->form->no_branding, + 'submission_id' => $this->getEncodedSubmissionId(), + ]; + } + + private function getEmailContent(): string + { + $parser = new MentionParser($this->integrationData->email_content ?? '', $this->formattedData); + return $parser->parse(); + } + + private function getEncodedSubmissionId(): ?string + { + $submissionId = $this->event->data['submission_id'] ?? null; + return $submissionId ? Hashids::encode($submissionId) : null; + } + + private function getRespondentEmail(): ?string + { + $emailFields = ['email', 'e-mail', 'mail']; + + foreach ($this->formattedData as $field => $value) { + if (in_array(strtolower($field), $emailFields) && $this->validateEmail($value)) { + return $value; + } + } + + // If no email field found, search for any field containing a valid email + foreach ($this->formattedData as $value) { + if ($this->validateEmail($value)) { + return $value; + } + } + + return null; + } + + public static function validateEmail($email): bool + { + return (bool)filter_var($email, FILTER_VALIDATE_EMAIL); + } +} diff --git a/api/app/Notifications/Forms/FormSubmissionNotification.php b/api/app/Notifications/Forms/FormSubmissionNotification.php deleted file mode 100644 index 342098df8..000000000 --- a/api/app/Notifications/Forms/FormSubmissionNotification.php +++ /dev/null @@ -1,113 +0,0 @@ -event = $event; - $this->mailer = $mailer; - } - - /** - * Get the notification's delivery channels. - * - * @param mixed $notifiable - * @return array - */ - public function via($notifiable) - { - return ['mail']; - } - - /** - * Get the mail representation of the notification. - * - * @param mixed $notifiable - * @return \Illuminate\Notifications\Messages\MailMessage - */ - public function toMail($notifiable) - { - $formatter = (new FormSubmissionFormatter($this->event->form, $this->event->data)) - ->showHiddenFields() - ->createLinks() - ->outputStringsOnly() - ->useSignedUrlForFiles(); - - return (new MailMessage()) - ->mailer($this->mailer) - ->replyTo($this->getReplyToEmail($notifiable->routes['mail'])) - ->from($this->getFromEmail(), config('app.name')) - ->subject('New form submission for "' . $this->event->form->title . '"') - ->markdown('mail.form.submission-notification', [ - 'fields' => $formatter->getFieldsWithValue(), - 'form' => $this->event->form, - ]); - } - - private function getFromEmail() - { - if (config('app.self_hosted')) { - return config('mail.from.address'); - } - $originalFromAddress = Str::of(config('mail.from.address'))->explode('@'); - - return $originalFromAddress->first() . '+' . time() . '@' . $originalFromAddress->last(); - } - - private function getReplyToEmail($default) - { - $replyTo = $this->integrationData->notification_reply_to ?? null; - if ($replyTo && $this->validateEmail($replyTo)) { - return $replyTo; - } - - return $this->getRespondentEmail() ?? $default; - } - - private function getRespondentEmail() - { - // Make sure we only have one email field in the form - $emailFields = collect($this->event->form->properties)->filter(function ($field) { - $hidden = $field['hidden'] ?? false; - - return !$hidden && $field['type'] == 'email'; - }); - if ($emailFields->count() != 1) { - return null; - } - - if (isset($this->event->data[$emailFields->first()['id']])) { - $email = $this->event->data[$emailFields->first()['id']]; - if ($this->validateEmail($email)) { - return $email; - } - } - - return null; - } - - public static function validateEmail($email): bool - { - return (bool) filter_var($email, FILTER_VALIDATE_EMAIL); - } -} diff --git a/api/app/Open/MentionParser.php b/api/app/Open/MentionParser.php new file mode 100644 index 000000000..01c8e3c80 --- /dev/null +++ b/api/app/Open/MentionParser.php @@ -0,0 +1,97 @@ +content = $content; + $this->data = $data; + } + + public function parse() + { + $doc = new DOMDocument(); + // Disable libxml errors and use internal errors + $internalErrors = libxml_use_internal_errors(true); + + // Wrap the content in a root element to ensure it's valid XML + $wrappedContent = '' . $this->content . ''; + + // Load HTML, using UTF-8 encoding + $doc->loadHTML(mb_convert_encoding($wrappedContent, 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + + // Restore libxml error handling + libxml_use_internal_errors($internalErrors); + + $xpath = new DOMXPath($doc); + $mentionElements = $xpath->query("//span[@mention]"); + + foreach ($mentionElements as $element) { + $fieldId = $element->getAttribute('mention-field-id'); + $fallback = $element->getAttribute('mention-fallback'); + $value = $this->getData($fieldId); + + if ($value !== null) { + $textNode = $doc->createTextNode(is_array($value) ? implode(', ', $value) : $value); + $element->parentNode->replaceChild($textNode, $element); + } elseif ($fallback) { + $textNode = $doc->createTextNode($fallback); + $element->parentNode->replaceChild($textNode, $element); + } else { + $element->parentNode->removeChild($element); + } + } + + // Extract and return the processed HTML content + $result = $doc->saveHTML($doc->getElementsByTagName('root')->item(0)); + + // Remove the root tags we added + $result = preg_replace('/<\/?root>/', '', $result); + + // Trim whitespace and convert HTML entities back to UTF-8 characters + $result = trim(html_entity_decode($result, ENT_QUOTES | ENT_HTML5, 'UTF-8')); + + return $result; + } + + private function replaceMentions() + { + $pattern = '/]*mention-field-id="([^"]*)"[^>]*mention-fallback="([^"]*)"[^>]*>.*?<\/span>/'; + return preg_replace_callback($pattern, function ($matches) { + $fieldId = $matches[1]; + $fallback = $matches[2]; + $value = $this->getData($fieldId); + + if ($value !== null) { + if (is_array($value)) { + return implode(' ', array_map(function ($v) { + return $v; + }, $value)); + } + return $value; + } elseif ($fallback) { + return $fallback; + } + return ''; + }, $this->content); + } + + private function getData($fieldId) + { + $value = collect($this->data)->firstWhere('id', $fieldId)['value'] ?? null; + + if (is_object($value)) { + return (array) $value; + } + + return $value; + } +} diff --git a/api/app/Service/HtmlPurifier/OpenFormsHtmlDefinition.php b/api/app/Service/HtmlPurifier/OpenFormsHtmlDefinition.php new file mode 100644 index 000000000..ba9fb807f --- /dev/null +++ b/api/app/Service/HtmlPurifier/OpenFormsHtmlDefinition.php @@ -0,0 +1,21 @@ +addAttribute('span', 'mention-field-id', 'Text'); + $definition->addAttribute('span', 'mention-field-name', 'Text'); + $definition->addAttribute('span', 'mention-fallback', 'Text'); + $definition->addAttribute('span', 'mention', 'Bool'); + $definition->addAttribute('span', 'contenteditable', 'Bool'); + } +} diff --git a/api/config/purify.php b/api/config/purify.php index 8a2bcbdca..44b2efb1d 100644 --- a/api/config/purify.php +++ b/api/config/purify.php @@ -1,6 +1,6 @@ [ 'default' => [ - 'HTML.Allowed' => 'h1,h2,b,u,strong,i,em,a[href|title],ul,ol,li,p,br,span,*[style]', + 'HTML.Allowed' => 'h1,h2,b,u,strong,i,em,a[href|title],ul,ol,li,p,br,span[mention|mention-field-id|mention-field-name|mention-fallback],*[style]', 'HTML.ForbiddenElements' => '', 'CSS.AllowedProperties' => 'font,font-size,font-weight,font-style,text-decoration,color,text-align', @@ -86,7 +86,7 @@ | */ - 'definitions' => Html5Definition::class, + 'definitions' => OpenFormsHtmlDefinition::class, /* |-------------------------------------------------------------------------- diff --git a/api/resources/data/forms/integrations.json b/api/resources/data/forms/integrations.json index 7eda1d009..a61ff56db 100644 --- a/api/resources/data/forms/integrations.json +++ b/api/resources/data/forms/integrations.json @@ -7,13 +7,6 @@ "is_pro": false, "crisp_help_page_slug": "can-i-receive-notifications-on-form-submissions-134svqv" }, - "submission_confirmation": { - "name": "Submission Confirmation", - "icon": "heroicons:paper-airplane-20-solid", - "section_name": "Notifications", - "file_name": "SubmissionConfirmationIntegration", - "is_pro": true - }, "slack": { "name": "Slack Notification", "icon": "mdi:slack", diff --git a/api/resources/views/mail/form/confirmation-submission-notification.blade.php b/api/resources/views/mail/form/confirmation-submission-notification.blade.php deleted file mode 100644 index 871998f30..000000000 --- a/api/resources/views/mail/form/confirmation-submission-notification.blade.php +++ /dev/null @@ -1,34 +0,0 @@ -@component('mail::message', ['noBranding' => $noBranding]) - -{!! $integrationData->notification_body !!} - -@if($form->editable_submissions) -@component('mail::button', ['url' => $form->share_url.'?submission_id='.$submission_id]) -{{($form->editable_submissions_button_text ?? 'Edit submission')}} -@endcomponent -@endif - -@if($integrationData->notifications_include_submission) -As a reminder, here are your answers: - -@foreach($fields as $field) -@if(isset($field['value'])) - --------------------------------------------------------------------------------- - -**{{$field['name']}}** -@if($field['type'] == 'files') -
-@foreach($field['email_data'] as $link) -{{$link['label']}}
-@endforeach -@else -{!! is_array($field['value'])?implode(',',$field['value']):$field['value']!!} -@endif -@endif -@endforeach -@endif - -

You are receiving this email because you answered the form: slug)}}">"{{$form->title}}".

- -@endcomponent \ No newline at end of file diff --git a/api/resources/views/mail/form/email-notification.blade.php b/api/resources/views/mail/form/email-notification.blade.php new file mode 100644 index 000000000..bf268d0ef --- /dev/null +++ b/api/resources/views/mail/form/email-notification.blade.php @@ -0,0 +1,22 @@ +@component('mail::message', ['noBranding' => $noBranding]) + +{!! $emailContent !!} + +@if($form->editable_submissions) +@component('mail::button', ['url' => $form->share_url.'?submission_id='.$submission_id]) +{{($form->editable_submissions_button_text ?? 'Edit submission')}} +@endcomponent +@endif + +@if($integrationData->include_submission_data) +@foreach($fields as $field) +@if(isset($field['value'])) +

+{{$field['name']}} +{!! is_array($field['value'])?implode(',',$field['value']):$field['value']!!} +

+@endif +@endforeach +@endif + +@endcomponent \ No newline at end of file diff --git a/api/resources/views/mail/form/submission-notification.blade.php b/api/resources/views/mail/form/submission-notification.blade.php deleted file mode 100644 index d2be7f62d..000000000 --- a/api/resources/views/mail/form/submission-notification.blade.php +++ /dev/null @@ -1,28 +0,0 @@ -@component('mail::message') - -Hello there πŸ‘‹ - -Your form "{{$form->title}}" has a new submission. - -@foreach($fields as $field) -@if(isset($field['value'])) - --------------------------------------------------------------------------------- - -**{{$field['name']}}** -@if($field['type'] == 'files') -
-@foreach($field['email_data'] as $link) -{{$link['label']}}
-@endforeach -@else -@if($field['type'] == 'matrix') -{!! nl2br(e($field['value'])) !!} -@else -{!! is_array($field['value'])?implode(',',$field['value']):$field['value']!!} -@endif -@endif -@endif -@endforeach - -@endcomponent diff --git a/api/tests/Feature/Forms/ConfirmationEmailTest.php b/api/tests/Feature/Forms/ConfirmationEmailTest.php deleted file mode 100644 index 83b07d5f9..000000000 --- a/api/tests/Feature/Forms/ConfirmationEmailTest.php +++ /dev/null @@ -1,144 +0,0 @@ -actingAsUser(); - $workspace = $this->createUserWorkspace($user); - $form = $this->createForm($user, $workspace); - $integrationData = $this->createFormIntegration('submission_confirmation', $form->id, [ - 'respondent_email' => true, - 'notifications_include_submission' => true, - 'notification_sender' => 'Custom Sender', - 'notification_subject' => 'Test subject', - 'notification_body' => 'Test body', - ]); - - $formData = [ - collect($form->properties)->first(function ($property) { - return $property['type'] == 'email'; - })['id'] => 'test@test.com', - ]; - $event = new \App\Events\Forms\FormSubmitted($form, $formData); - $mailable = new SubmissionConfirmationMail($event, $integrationData); - $mailable->assertSeeInHtml('Test body') - ->assertSeeInHtml('As a reminder, here are your answers:') - ->assertSeeInHtml('You are receiving this email because you answered the form:'); -}); - -it('creates confirmation emails without the submitted data', function () { - $user = $this->actingAsUser(); - $workspace = $this->createUserWorkspace($user); - $form = $this->createForm($user, $workspace); - $integrationData = $this->createFormIntegration('submission_confirmation', $form->id, [ - 'respondent_email' => true, - 'notifications_include_submission' => false, - 'notification_sender' => 'Custom Sender', - 'notification_subject' => 'Test subject', - 'notification_body' => 'Test body', - ]); - - $formData = [ - collect($form->properties)->first(function ($property) { - return $property['type'] == 'email'; - })['id'] => 'test@test.com', - ]; - $event = new \App\Events\Forms\FormSubmitted($form, $formData); - $mailable = new SubmissionConfirmationMail($event, $integrationData); - $mailable->assertSeeInHtml('Test body') - ->assertDontSeeInHtml('As a reminder, here are your answers:') - ->assertSeeInHtml('You are receiving this email because you answered the form:'); -}); - -it('sends a confirmation email if needed', function () { - $user = $this->actingAsProUser(); - $workspace = $this->createUserWorkspace($user); - $form = $this->createForm($user, $workspace); - - $this->createFormIntegration('submission_confirmation', $form->id, [ - 'respondent_email' => true, - 'notifications_include_submission' => true, - 'notification_sender' => 'Custom Sender', - 'notification_subject' => 'Test subject', - 'notification_body' => 'Test body', - ]); - - $emailProperty = collect($form->properties)->first(function ($property) { - return $property['type'] == 'email'; - }); - $formData = [ - $emailProperty['id'] => 'test@test.com', - ]; - - Mail::fake(); - - $this->postJson(route('forms.answer', $form->slug), $formData) - ->assertSuccessful() - ->assertJson([ - 'type' => 'success', - 'message' => 'Form submission saved.', - ]); - - Mail::assertQueued( - SubmissionConfirmationMail::class, - function (SubmissionConfirmationMail $mail) { - return $mail->hasTo('test@test.com'); - } - ); -}); - -it('does not send a confirmation email if not needed', function () { - $user = $this->actingAsUser(); - $workspace = $this->createUserWorkspace($user); - $form = $this->createForm($user, $workspace); - $emailProperty = collect($form->properties)->first(function ($property) { - return $property['type'] == 'email'; - }); - $formData = [ - $emailProperty['id'] => 'test@test.com', - ]; - - Mail::fake(); - - $this->postJson(route('forms.answer', $form->slug), $formData) - ->assertSuccessful() - ->assertJson([ - 'type' => 'success', - 'message' => 'Form submission saved.', - ]); - - Mail::assertNotQueued( - SubmissionConfirmationMail::class, - function (SubmissionConfirmationMail $mail) { - return $mail->hasTo('test@test.com'); - } - ); -}); - -it('does send a confirmation email even when reply to is broken', function () { - $user = $this->actingAsProUser(); - $workspace = $this->createUserWorkspace($user); - $form = $this->createForm($user, $workspace); - $integrationData = $this->createFormIntegration('submission_confirmation', $form->id, [ - 'respondent_email' => true, - 'notifications_include_submission' => true, - 'notification_sender' => 'Custom Sender', - 'notification_subject' => 'Test subject', - 'notification_body' => 'Test body', - 'confirmation_reply_to' => '' - ]); - - $emailProperty = collect($form->properties)->first(function ($property) { - return $property['type'] == 'email'; - }); - $formData = [ - $emailProperty['id'] => 'test@test.com', - ]; - $event = new \App\Events\Forms\FormSubmitted($form, $formData); - $mailable = new SubmissionConfirmationMail($event, $integrationData); - $mailable->assertSeeInHtml('Test body') - ->assertSeeInHtml('As a reminder, here are your answers:') - ->assertSeeInHtml('You are receiving this email because you answered the form:') - ->assertHasReplyTo($user->email); // Even though reply to is wrong, it should use the user's email -}); diff --git a/api/tests/Feature/Forms/CustomSmtpTest.php b/api/tests/Feature/Forms/CustomSmtpTest.php index 065e77e9f..d82f8f7de 100644 --- a/api/tests/Feature/Forms/CustomSmtpTest.php +++ b/api/tests/Feature/Forms/CustomSmtpTest.php @@ -1,7 +1,8 @@ actingAsUser(); @@ -15,7 +16,7 @@ ])->assertStatus(403); }); -it('creates confirmation emails with custom SMTP settings', function () { +it('send email with custom SMTP settings', function () { $user = $this->actingAsProUser(); $workspace = $this->createUserWorkspace($user); $form = $this->createForm($user, $workspace); @@ -28,21 +29,19 @@ 'password' => 'custom_password', ])->assertSuccessful(); - $integrationData = $this->createFormIntegration('submission_confirmation', $form->id, [ - 'respondent_email' => true, - 'notifications_include_submission' => true, - 'notification_sender' => 'Custom Sender', - 'notification_subject' => 'Custom SMTP Test', - 'notification_body' => 'This email was sent using custom SMTP settings', + $integrationData = $this->createFormIntegration('email', $form->id, [ + 'send_to' => 'test@test.com', + 'sender_name' => 'OpnForm', + 'subject' => 'New form submission', + 'email_content' => 'Hello there πŸ‘‹
New form submission received.', + 'include_submission_data' => true, + 'include_hidden_fields_submission_data' => false, + 'reply_to' => 'reply@example.com', ]); - $formData = [ - collect($form->properties)->first(function ($property) { - return $property['type'] == 'email'; - })['id'] => 'test@test.com', - ]; + $formData = FormSubmissionDataFactory::generateSubmissionData($form); - Mail::fake(); + Notification::fake(); $this->postJson(route('forms.answer', $form->slug), $formData) ->assertSuccessful() @@ -51,10 +50,12 @@ 'message' => 'Form submission saved.', ]); - Mail::assertQueued( - SubmissionConfirmationMail::class, - function (SubmissionConfirmationMail $mail) { - return $mail->hasTo('test@test.com') && $mail->mailer === 'custom_smtp'; + Notification::assertSentTo( + new AnonymousNotifiable(), + FormEmailNotification::class, + function (FormEmailNotification $notification, $channels, $notifiable) { + return $notifiable->routes['mail'] === 'test@test.com' && + $notification->mailer === 'custom_smtp'; } ); }); diff --git a/api/tests/Feature/Forms/EmailNotificationTest.php b/api/tests/Feature/Forms/EmailNotificationTest.php new file mode 100644 index 000000000..03cf5acf4 --- /dev/null +++ b/api/tests/Feature/Forms/EmailNotificationTest.php @@ -0,0 +1,165 @@ +actingAsUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace); + $integrationData = $this->createFormIntegration('email', $form->id, [ + 'send_to' => 'test@test.com', + 'sender_name' => 'OpnForm', + 'subject' => 'New form submission', + 'email_content' => 'Hello there πŸ‘‹
Test body', + 'include_submission_data' => true, + 'include_hidden_fields_submission_data' => false, + 'reply_to' => 'reply@example.com', + ]); + + $formData = FormSubmissionDataFactory::generateSubmissionData($form); + + $event = new \App\Events\Forms\FormSubmitted($form, $formData); + $mailable = new FormEmailNotification($event, $integrationData, 'mail'); + $notifiable = new AnonymousNotifiable(); + $notifiable->route('mail', 'test@test.com'); + $renderedMail = $mailable->toMail($notifiable); + expect($renderedMail->subject)->toBe('New form submission'); + expect(trim($renderedMail->render()))->toContain('Test body'); +}); + +it('sends a email if needed', function () { + $user = $this->actingAsProUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace); + + $emailProperty = collect($form->properties)->first(function ($property) { + return $property['type'] == 'email'; + }); + + $this->createFormIntegration('email', $form->id, [ + 'send_to' => '' . $emailProperty['name'] . '', + 'sender_name' => 'OpnForm', + 'subject' => 'New form submission', + 'email_content' => 'Hello there πŸ‘‹
New form submission received.', + 'include_submission_data' => true, + 'include_hidden_fields_submission_data' => false, + 'reply_to' => 'reply@example.com', + ]); + + $formData = [ + $emailProperty['id'] => 'test@test.com', + ]; + + Notification::fake(); + + $this->postJson(route('forms.answer', $form->slug), $formData) + ->assertSuccessful() + ->assertJson([ + 'type' => 'success', + 'message' => 'Form submission saved.', + ]); + + Notification::assertSentTo( + new AnonymousNotifiable(), + FormEmailNotification::class, + function (FormEmailNotification $notification, $channels, $notifiable) { + return $notifiable->routes['mail'] === 'test@test.com'; + } + ); +}); + +it('does not send a email if not needed', function () { + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace); + $emailProperty = collect($form->properties)->first(function ($property) { + return $property['type'] == 'email'; + }); + $formData = [ + $emailProperty['id'] => 'test@test.com', + ]; + + Notification::fake(); + + $this->postJson(route('forms.answer', $form->slug), $formData) + ->assertSuccessful() + ->assertJson([ + 'type' => 'success', + 'message' => 'Form submission saved.', + ]); + + Notification::assertNotSentTo( + new AnonymousNotifiable(), + FormEmailNotification::class, + function (FormEmailNotification $notification, $channels, $notifiable) { + return $notifiable->routes['mail'] === 'test@test.com'; + } + ); +}); + +it('uses custom sender email in self-hosted mode', function () { + config(['app.self_hosted' => true]); + + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace); + $customSenderEmail = 'custom@example.com'; + $integrationData = $this->createFormIntegration('email', $form->id, [ + 'send_to' => 'test@test.com', + 'sender_name' => 'Custom Sender', + 'sender_email' => $customSenderEmail, + 'subject' => 'Custom Subject', + 'email_content' => 'Custom content', + 'include_submission_data' => true, + 'include_hidden_fields_submission_data' => false, + 'reply_to' => 'reply@example.com', + ]); + + $formData = FormSubmissionDataFactory::generateSubmissionData($form); + + $event = new \App\Events\Forms\FormSubmitted($form, $formData); + $mailable = new FormEmailNotification($event, $integrationData, 'mail'); + $notifiable = new AnonymousNotifiable(); + $notifiable->route('mail', 'test@test.com'); + $renderedMail = $mailable->toMail($notifiable); + + expect($renderedMail->from[0])->toBe($customSenderEmail); + expect($renderedMail->from[1])->toBe('Custom Sender'); + expect($renderedMail->subject)->toBe('Custom Subject'); + expect(trim($renderedMail->render()))->toContain('Custom content'); +}); + +it('does not use custom sender email in non-self-hosted mode', function () { + config(['app.self_hosted' => false]); + config(['mail.from.address' => 'default@example.com']); + + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace); + $customSenderEmail = 'custom@example.com'; + $integrationData = $this->createFormIntegration('email', $form->id, [ + 'send_to' => 'test@test.com', + 'sender_name' => 'Custom Sender', + 'sender_email' => $customSenderEmail, + 'subject' => 'Custom Subject', + 'email_content' => 'Custom content', + 'include_submission_data' => true, + 'include_hidden_fields_submission_data' => false, + 'reply_to' => 'reply@example.com', + ]); + + $formData = FormSubmissionDataFactory::generateSubmissionData($form); + + $event = new \App\Events\Forms\FormSubmitted($form, $formData); + $mailable = new FormEmailNotification($event, $integrationData, 'mail'); + $notifiable = new AnonymousNotifiable(); + $notifiable->route('mail', 'test@test.com'); + $renderedMail = $mailable->toMail($notifiable); + + expect($renderedMail->from[0])->toBe('default@example.com'); + expect($renderedMail->from[1])->toBe('Custom Sender'); + expect($renderedMail->subject)->toBe('Custom Subject'); + expect(trim($renderedMail->render()))->toContain('Custom content'); +}); diff --git a/api/tests/Feature/Forms/FormIntegrationEventTest.php b/api/tests/Feature/Forms/FormIntegrationEventTest.php index 487a70ed5..be1377a44 100644 --- a/api/tests/Feature/Forms/FormIntegrationEventTest.php +++ b/api/tests/Feature/Forms/FormIntegrationEventTest.php @@ -10,8 +10,13 @@ 'integration_id' => 'email', 'logic' => null, 'settings' => [ - 'notification_emails' => 'test@test.com', - 'notification_reply_to' => null + 'send_to' => 'test@test.com', + 'sender_name' => 'OpnForm', + 'subject' => 'New form submission', + 'email_content' => 'Hello there πŸ‘‹
New form submission received.', + 'include_submission_data' => true, + 'include_hidden_fields_submission_data' => false, + 'reply_to' => null ] ]; diff --git a/api/tests/Feature/Forms/FormIntegrationTest.php b/api/tests/Feature/Forms/FormIntegrationTest.php index 9478d9cb7..5d1914803 100644 --- a/api/tests/Feature/Forms/FormIntegrationTest.php +++ b/api/tests/Feature/Forms/FormIntegrationTest.php @@ -10,8 +10,13 @@ 'integration_id' => 'email', 'logic' => null, 'settings' => [ - 'notification_emails' => 'test@test.com', - 'notification_reply_to' => null + 'send_to' => 'test@test.com', + 'sender_name' => 'OpnForm', + 'subject' => 'New form submission', + 'email_content' => 'Hello there πŸ‘‹
New form submission received.', + 'include_submission_data' => true, + 'include_hidden_fields_submission_data' => false, + 'reply_to' => null ] ]; diff --git a/api/tests/Unit/EmailNotificationMigrationTest.php b/api/tests/Unit/EmailNotificationMigrationTest.php new file mode 100644 index 000000000..d678715de --- /dev/null +++ b/api/tests/Unit/EmailNotificationMigrationTest.php @@ -0,0 +1,80 @@ +command = new EmailNotificationMigration(); +}); + +it('updates email integration correctly', function () { + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace); + + $integration = FormIntegration::create([ + 'integration_id' => 'email', + 'form_id' => $form->id, + 'status' => FormIntegration::STATUS_ACTIVE, + 'data' => [ + 'notification_emails' => 'test@example.com', + 'notification_reply_to' => 'reply@example.com', + ], + ]); + + $this->command->updateIntegration($integration); + + expect($integration->fresh()) + ->integration_id->toBe('email') + ->data->toMatchArray([ + 'send_to' => 'test@example.com', + 'sender_name' => 'OpnForm', + 'subject' => 'New form submission', + 'email_content' => 'Hello there πŸ‘‹
New form submission received.', + 'include_submission_data' => true, + 'include_hidden_fields_submission_data' => false, + 'reply_to' => 'reply@example.com', + ]); +}); + +it('updates submission confirmation integration correctly', function () { + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace); + + $emailProperty = collect($form->properties)->filter(function ($property) { + return $property['type'] == 'email'; + })->first(); + + $integration = FormIntegration::create([ + 'integration_id' => 'submission_confirmation', + 'form_id' => $form->id, + 'status' => FormIntegration::STATUS_ACTIVE, + 'data' => [ + 'notification_sender' => 'Sender Name', + 'notification_subject' => 'Thank you for your submission', + 'notification_body' => 'We received your submission.', + 'notifications_include_submission' => true, + 'confirmation_reply_to' => 'reply@example.com', + ], + ]); + + $this->command->updateIntegration($integration); + + expect($integration->fresh()) + ->integration_id->toBe('email') + ->data->toMatchArray([ + 'send_to' => '' . $emailProperty['name'] . '', + 'sender_name' => 'Sender Name', + 'subject' => 'Thank you for your submission', + 'email_content' => 'We received your submission.', + 'include_submission_data' => true, + 'include_hidden_fields_submission_data' => false, + 'reply_to' => 'reply@example.com', + ]); +}); diff --git a/api/tests/Unit/Service/Forms/MentionParserTest.php b/api/tests/Unit/Service/Forms/MentionParserTest.php new file mode 100644 index 000000000..182839901 --- /dev/null +++ b/api/tests/Unit/Service/Forms/MentionParserTest.php @@ -0,0 +1,86 @@ +Hello Placeholder

'; + $data = [['id' => '123', 'value' => 'World']]; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('

Hello World

'); +}); + +test('it handles multiple mentions', function () { + $content = '

Name is Age years old

'; + $data = [ + ['id' => '123', 'value' => 'John'], + ['id' => '456', 'value' => 30], + ]; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('

John is 30 years old

'); +}); + +test('it uses fallback when value is not found', function () { + $content = '

Hello Placeholder

'; + $data = []; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('

Hello Friend

'); +}); + +test('it removes mention element when no value and no fallback', function () { + $content = '

Hello Placeholder

'; + $data = []; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('

Hello

'); +}); + +test('it handles array values', function () { + $content = '

Tags: Placeholder

'; + $data = [['id' => '123', 'value' => ['PHP', 'Laravel', 'Testing']]]; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('

Tags: PHP, Laravel, Testing

'); +}); + +test('it preserves HTML structure', function () { + $content = '

Hello Placeholder

How are you?

'; + $data = [['id' => '123', 'value' => 'World']]; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('

Hello World

How are you?

'); +}); + +test('it handles UTF-8 characters', function () { + $content = '

こんにけは Placeholder

'; + $data = [['id' => '123', 'value' => 'δΈ–η•Œ']]; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('

こんにけは δΈ–η•Œ

'); +}); + +test('it handles content without surrounding paragraph tags', function () { + $content = 'some text Post excerpt dewde'; + $data = [['id' => '123', 'value' => 'replaced text']]; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('some text replaced text dewde'); +}); diff --git a/client/components/forms/MentionInput.vue b/client/components/forms/MentionInput.vue new file mode 100644 index 000000000..85a56e3f5 --- /dev/null +++ b/client/components/forms/MentionInput.vue @@ -0,0 +1,199 @@ + + + + + \ No newline at end of file diff --git a/client/components/forms/RichTextAreaInput.client.vue b/client/components/forms/RichTextAreaInput.client.vue index 26143045c..462ac10d8 100644 --- a/client/components/forms/RichTextAreaInput.client.vue +++ b/client/components/forms/RichTextAreaInput.client.vue @@ -4,12 +4,8 @@ - + :style="{ + '--font-size': theme.default.fontSize + }" + > + + - +.ql-mention { + padding-top: 0px !important; + margin-top: -5px !important; +} +.ql-mention::after { + content: '@'; + font-size: 16px; +} +.rich-editor, .mention-input { + span[mention] { + @apply inline-flex items-center align-baseline leading-tight text-sm relative bg-blue-100 text-blue-800 border border-blue-200 rounded-md px-1 py-0.5 mx-0.5; + max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + \ No newline at end of file diff --git a/client/components/forms/TextBlock.vue b/client/components/forms/TextBlock.vue new file mode 100644 index 000000000..8a2c7c641 --- /dev/null +++ b/client/components/forms/TextBlock.vue @@ -0,0 +1,29 @@ + + + \ No newline at end of file diff --git a/client/components/forms/components/FormSubmissionFormatter.js b/client/components/forms/components/FormSubmissionFormatter.js new file mode 100644 index 000000000..ea7cab15e --- /dev/null +++ b/client/components/forms/components/FormSubmissionFormatter.js @@ -0,0 +1,105 @@ +import { format, parseISO } from 'date-fns' + +export class FormSubmissionFormatter { + constructor(form, formData) { + this.form = form + this.formData = formData + this.createLinks = false + this.outputStringsOnly = false + this.showGeneratedIds = false + this.datesIsoFormat = false + } + + setCreateLinks() { + this.createLinks = true + return this + } + + setShowGeneratedIds() { + this.showGeneratedIds = true + return this + } + + setOutputStringsOnly() { + this.outputStringsOnly = true + return this + } + + setUseIsoFormatForDates() { + this.datesIsoFormat = true + return this + } + + getFormattedData() { + const formattedData = {} + + this.form.properties.forEach(field => { + if (!this.formData[field.id] && !this.fieldGeneratesId(field)) { + return + } + + const value = this.formatFieldValue(field, this.formData[field.id]) + formattedData[field.id] = value + }) + + return formattedData + } + + formatFieldValue(field, value) { + switch (field.type) { + case 'url': + return this.createLinks ? `${value}` : value + case 'email': + return this.createLinks ? `${value}` : value + case 'checkbox': + return value ? 'Yes' : 'No' + case 'date': + return this.formatDateValue(field, value) + case 'people': + return this.formatPeopleValue(value) + case 'multi_select': + return this.outputStringsOnly ? value.join(', ') : value + case 'relation': + return this.formatRelationValue(value) + default: + return Array.isArray(value) && this.outputStringsOnly ? value.join(', ') : value + } + } + + formatDateValue(field, value) { + if (this.datesIsoFormat) { + return Array.isArray(value) + ? { start_date: value[0], end_date: value[1] || null } + : value + } + + const dateFormat = (field.date_format || 'dd/MM/yyyy') === 'dd/MM/yyyy' ? 'dd/MM/yyyy' : 'MM/dd/yyyy' + const timeFormat = field.with_time ? (field.time_format === 24 ? 'HH:mm' : 'h:mm a') : '' + const fullFormat = `${dateFormat}${timeFormat ? ' ' + timeFormat : ''}` + + if (Array.isArray(value)) { + const start = format(parseISO(value[0]), fullFormat) + const end = value[1] ? format(parseISO(value[1]), fullFormat) : null + return end ? `${start} - ${end}` : start + } + + return format(parseISO(value), fullFormat) + } + + formatPeopleValue(value) { + if (!value) return [] + const people = Array.isArray(value) ? value : [value] + return this.outputStringsOnly ? people.map(p => p.name).join(', ') : people + } + + formatRelationValue(value) { + if (!value) return [] + const relations = Array.isArray(value) ? value : [value] + const formatted = relations.map(r => r.title || 'Untitled') + return this.outputStringsOnly ? formatted.join(', ') : formatted + } + + fieldGeneratesId(field) { + return this.showGeneratedIds && (field.generates_auto_increment_id || field.generates_uuid) + } +} diff --git a/client/components/forms/components/MentionDropdown.vue b/client/components/forms/components/MentionDropdown.vue new file mode 100644 index 000000000..1e7b116cc --- /dev/null +++ b/client/components/forms/components/MentionDropdown.vue @@ -0,0 +1,116 @@ + + + \ No newline at end of file diff --git a/client/components/forms/components/QuillyEditor.vue b/client/components/forms/components/QuillyEditor.vue new file mode 100644 index 000000000..854cb714c --- /dev/null +++ b/client/components/forms/components/QuillyEditor.vue @@ -0,0 +1,98 @@ + + + \ No newline at end of file diff --git a/client/components/global/Modal.vue b/client/components/global/Modal.vue index c3d901786..cb5ca5c57 100644 --- a/client/components/global/Modal.vue +++ b/client/components/global/Modal.vue @@ -4,8 +4,7 @@
import { watch } from 'vue' import { default as _has } from 'lodash/has' +import {twMerge} from 'tailwind-merge' const props = defineProps({ show: { diff --git a/client/components/open/forms/OpenCompleteForm.vue b/client/components/open/forms/OpenCompleteForm.vue index 53f004c1d..80383e8c9 100644 --- a/client/components/open/forms/OpenCompleteForm.vue +++ b/client/components/open/forms/OpenCompleteForm.vue @@ -140,9 +140,13 @@ key="submitted" class="px-2" > -

{ + this.submittedData = form.data() useAmplitude().logEvent('form_submission', { workspace_id: this.form.workspace_id, form_id: this.form.id diff --git a/client/components/open/forms/components/FirstSubmissionModal.vue b/client/components/open/forms/components/FirstSubmissionModal.vue index 643c0e2b1..619d63947 100644 --- a/client/components/open/forms/components/FirstSubmissionModal.vue +++ b/client/components/open/forms/components/FirstSubmissionModal.vue @@ -2,7 +2,7 @@ @@ -40,22 +88,19 @@ const props = defineProps({ formIntegrationId: { type: Number, required: false, default: null }, }) -const replayToEmailField = computed(() => { - const emailFields = props.form.properties.filter((field) => { - return field.type === "email" && !field.hidden - }) - if (emailFields.length === 1) return emailFields[0] - return null -}) +const selfHosted = computed(() => useFeatureFlag('self_hosted')) -const notifiesHelp = computed(() => { - if (replayToEmailField.value) { - return ( - 'If empty, Reply-to for this notification will be the email filled in the field "' + - replayToEmailField.value.name + - '".' - ) +onBeforeMount(() => { + for (const [keyname, defaultValue] of Object.entries({ + sender_name: "OpnForm", + subject: "We saved your answers", + email_content: "Hello there πŸ‘‹
This is a confirmation that your submission was successfully saved.", + include_submission_data: true, + include_hidden_fields_submission_data: false, + })) { + if (props.integrationData.settings[keyname] === undefined) { + props.integrationData.settings[keyname] = defaultValue + } } - return "If empty, Reply-to for this notification will be your own email. Add a single email field to your form, and it will automatically become the reply to value." }) diff --git a/client/components/open/integrations/SlackIntegration.vue b/client/components/open/integrations/SlackIntegration.vue index 7bbb72480..1cd5a60d5 100644 --- a/client/components/open/integrations/SlackIntegration.vue +++ b/client/components/open/integrations/SlackIntegration.vue @@ -29,7 +29,10 @@

Slack message actions

- + diff --git a/client/components/open/integrations/SubmissionConfirmationIntegration.vue b/client/components/open/integrations/SubmissionConfirmationIntegration.vue deleted file mode 100644 index 6a9885d8c..000000000 --- a/client/components/open/integrations/SubmissionConfirmationIntegration.vue +++ /dev/null @@ -1,108 +0,0 @@ - - - diff --git a/client/components/open/integrations/components/IntegrationWrapper.vue b/client/components/open/integrations/components/IntegrationWrapper.vue index ea848ff73..69833b5aa 100644 --- a/client/components/open/integrations/components/IntegrationWrapper.vue +++ b/client/components/open/integrations/components/IntegrationWrapper.vue @@ -11,19 +11,18 @@ label="Enabled" /> - - + - - Help - + Help +
diff --git a/client/components/open/integrations/components/NotificationsMessageActions.vue b/client/components/open/integrations/components/NotificationsMessageActions.vue index 6dc6fabf6..fa81e2f16 100644 --- a/client/components/open/integrations/components/NotificationsMessageActions.vue +++ b/client/components/open/integrations/components/NotificationsMessageActions.vue @@ -1,5 +1,13 @@