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 You are receiving this email because you answered the form: slug)}}">"{{$form->title}}".
+{{$field['name']}}
+{!! is_array($field['value'])?implode(',',$field['value']):$field['value']!!}
+
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 = '
-@foreach($field['email_data'] as $link)
-{{$link['label']}}
-@endforeach
-@else
-{!! is_array($field['value'])?implode(',',$field['value']):$field['value']!!}
-@endif
-@endif
-@endforeach
-@endif
-
-
-@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
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?
Hello World
How are you?
γγγ«γ‘γ― 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 @@ + ++ {{ field.name }} +
+
- You can