From 6c056f26925678b826b28d3c1c0de9dead3fca08 Mon Sep 17 00:00:00 2001
From: Frank
Date: Wed, 9 Oct 2024 14:26:13 +0100
Subject: [PATCH 01/16] variables and mentions
---
.../SubmissionConfirmationIntegration.php | 3 +-
.../Mail/Forms/SubmissionConfirmationMail.php | 39 +++-
api/app/Open/MentionParser.php | 55 +++++
.../HtmlPurifier/OpenFormsHtmlDefinition.php | 21 ++
api/config/purify.php | 6 +-
client/components/forms/MentionInput.vue | 189 ++++++++++++++++++
.../forms/RichTextAreaInput.client.vue | 96 ++++++---
client/components/forms/TextBlock.vue | 49 +++++
.../components/FormSubmissionFormatter.js | 105 ++++++++++
.../forms/components/MentionDropdown.vue | 116 +++++++++++
.../open/forms/OpenCompleteForm.vue | 12 +-
.../forms/components/FirstSubmissionModal.vue | 2 +-
.../FormSubmissionSettings.vue | 2 +
.../SubmissionConfirmationIntegration.vue | 18 +-
client/data/blocks_types.json | 60 ++++--
client/lib/quill/quillMentionExtension.js | 177 ++++++++++++++++
16 files changed, 880 insertions(+), 70 deletions(-)
create mode 100644 api/app/Open/MentionParser.php
create mode 100644 api/app/Service/HtmlPurifier/OpenFormsHtmlDefinition.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/lib/quill/quillMentionExtension.js
diff --git a/api/app/Integrations/Handlers/SubmissionConfirmationIntegration.php b/api/app/Integrations/Handlers/SubmissionConfirmationIntegration.php
index 8b40fe4a6..a556e13c5 100644
--- a/api/app/Integrations/Handlers/SubmissionConfirmationIntegration.php
+++ b/api/app/Integrations/Handlers/SubmissionConfirmationIntegration.php
@@ -26,7 +26,7 @@ function ($attribute, $value, $fail) {
}
},
],
- 'confirmation_reply_to' => 'email|nullable',
+ 'confirmation_reply_to' => 'nullable',
'notification_sender' => 'required',
'notification_subject' => 'required',
'notification_body' => 'required',
@@ -107,6 +107,7 @@ public static function validateEmail($email): bool
public static function formatData(array $data): array
{
return array_merge(parent::formatData($data), [
+ 'notification_subject' => Purify::clean($data['notification_subject'] ?? ''),
'notification_body' => Purify::clean($data['notification_body'] ?? ''),
]);
}
diff --git a/api/app/Mail/Forms/SubmissionConfirmationMail.php b/api/app/Mail/Forms/SubmissionConfirmationMail.php
index b50bb9c1e..2b0d249ee 100644
--- a/api/app/Mail/Forms/SubmissionConfirmationMail.php
+++ b/api/app/Mail/Forms/SubmissionConfirmationMail.php
@@ -4,6 +4,7 @@
use App\Events\Forms\FormSubmitted;
use App\Mail\OpenFormMail;
+use App\Open\MentionParser;
use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -16,6 +17,8 @@ class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue
use Queueable;
use SerializesModels;
+ private $formattedData;
+
/**
* Create a new message instance.
*
@@ -23,6 +26,12 @@ class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue
*/
public function __construct(private FormSubmitted $event, private $integrationData)
{
+ $formatter = (new FormSubmissionFormatter($event->form, $event->data))
+ ->createLinks()
+ ->outputStringsOnly()
+ ->useSignedUrlForFiles();
+
+ $this->formattedData = $formatter->getFieldsWithValue();
}
/**
@@ -34,17 +43,13 @@ public function build()
{
$form = $this->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)
+ ->subject($this->getSubject())
->markdown('mail.form.confirmation-submission-notification', [
- 'fields' => $formatter->getFieldsWithValue(),
+ 'fields' => $this->formattedData,
'form' => $form,
'integrationData' => $this->integrationData,
'noBranding' => $form->no_branding,
@@ -67,9 +72,25 @@ private function getReplyToEmail($default)
{
$replyTo = $this->integrationData->confirmation_reply_to ?? null;
- if ($replyTo && filter_var($replyTo, FILTER_VALIDATE_EMAIL)) {
- return $replyTo;
+ if ($replyTo) {
+ $parser = new MentionParser($replyTo, $this->formattedData);
+ $parsedReplyTo = $parser->parse();
+ if ($parsedReplyTo && filter_var($parsedReplyTo, FILTER_VALIDATE_EMAIL)) {
+ return $parsedReplyTo;
+ }
}
return $default;
}
+
+ private function getSubject()
+ {
+ $parser = new MentionParser($this->integrationData->notification_subject, $this->formattedData);
+ return $parser->parse();
+ }
+
+ private function getNotificationBody()
+ {
+ $parser = new MentionParser($this->integrationData->notification_body, $this->formattedData);
+ return $parser->parse();
+ }
}
diff --git a/api/app/Open/MentionParser.php b/api/app/Open/MentionParser.php
new file mode 100644
index 000000000..f520763c5
--- /dev/null
+++ b/api/app/Open/MentionParser.php
@@ -0,0 +1,55 @@
+content = $content;
+ $this->data = $data;
+ }
+
+ public function parse()
+ {
+ return $this->replaceMentions();
+ }
+
+ 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)->filter(function ($item) use ($fieldId) {
+ return $item['id'] === $fieldId;
+ })->first()['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 24c8d1ce4..a7c77e8a7 100644
--- a/api/config/purify.php
+++ b/api/config/purify.php
@@ -1,6 +1,6 @@
[
'default' => [
- 'HTML.Allowed' => 'h1,h2,b,strong,i,em,a[href|title],ul,ol,li,p,br,span,*[style]',
+ 'HTML.Allowed' => 'h1,h2,b,strong,i,em,a[href|title],ul,ol,li,p,br,span[mention|mention-field-id|mention-field-name|mention-fallback|contenteditable],*[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/client/components/forms/MentionInput.vue b/client/components/forms/MentionInput.vue
new file mode 100644
index 000000000..fd70c464b
--- /dev/null
+++ b/client/components/forms/MentionInput.vue
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/components/forms/RichTextAreaInput.client.vue b/client/components/forms/RichTextAreaInput.client.vue
index 26143045c..48f0da479 100644
--- a/client/components/forms/RichTextAreaInput.client.vue
+++ b/client/components/forms/RichTextAreaInput.client.vue
@@ -24,12 +24,25 @@
:style="inputStyle"
/>
+
+
+
+
+
@@ -37,49 +50,74 @@
import { Quill, VueEditor } from 'vue3-editor'
import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue'
+import MentionDropdown from './components/MentionDropdown.vue'
+import registerMentionExtension from '~/lib/quill/quillMentionExtension.js'
Quill.imports['formats/link'].PROTOCOL_WHITELIST.push('notion')
export default {
name: 'RichTextAreaInput',
- components: { InputWrapper, VueEditor },
+ components: { InputWrapper, VueEditor, MentionDropdown },
props: {
...inputProps,
editorOptions: {
type: Object,
- default: () => {
- return {
- formats: [
- 'bold',
- 'color',
- 'font',
- 'italic',
- 'link',
- 'underline',
- 'header',
- 'indent',
- 'list'
- ]
+ default: () => ({
+ formats: [
+ 'bold',
+ 'color',
+ 'font',
+ 'italic',
+ 'link',
+ 'underline',
+ 'header',
+ 'indent',
+ 'list',
+ 'mention'
+ ],
+ modules: {
+ mention: {
+ mentions: [] // This will be populated with form fields
+ }
}
- }
+ })
},
editorToolbar: {
type: Array,
- default: () => {
- return [
- [{ header: 1 }, { header: 2 }],
- ['bold', 'italic', 'underline', 'link'],
- [{ list: 'ordered' }, { list: 'bullet' }],
- [{ color: [] }]
- ]
- }
+ default: () => [
+ [{ header: 1 }, { header: 2 }],
+ ['bold', 'italic', 'underline', 'link'],
+ [{ list: 'ordered' }, { list: 'bullet' }],
+ [{ color: [] }]
+ ]
+ },
+ mentions: {
+ type: Array,
+ default: () => []
+ },
+ enableMentions: {
+ type: Boolean,
+ default: false
}
},
- setup (props, context) {
+ setup(props, context) {
+ const editorOptions = {
+ ...props.editorOptions,
+ modules: {
+ ...props.editorOptions.modules,
+ mention: props.enableMentions ? { mentions: props.mentions } : undefined
+ }
+ }
+ const editorToolbar = props.enableMentions
+ ? [...props.editorToolbar, ['mention']]
+ : props.editorToolbar
return {
- ...useFormInput(props, context)
+ ...useFormInput(props, context),
+ editorOptions,
+ editorToolbar,
+ mentionState: registerMentionExtension(Quill)
}
}
}
@@ -120,4 +158,12 @@ export default {
@apply text-nt-blue;
}
}
+
+.ql-mention::after {
+ content: '@';
+ font-size: 18px;
+}
+span[mention] {
+ @apply max-w-[150px] truncate overflow-hidden bg-blue-100 text-blue-800 border border-blue-200 rounded-md px-1 inline-flex items-center align-baseline leading-tight text-sm relative;
+}
diff --git a/client/components/forms/TextBlock.vue b/client/components/forms/TextBlock.vue
new file mode 100644
index 000000000..71b3218dc
--- /dev/null
+++ b/client/components/forms/TextBlock.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
\ 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..49dcd0398
--- /dev/null
+++ b/client/components/forms/components/MentionDropdown.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+ Insert
+
+
+ Cancel
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/components/open/forms/OpenCompleteForm.vue b/client/components/open/forms/OpenCompleteForm.vue
index d0eb36516..12ec56747 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 25fbbf990..333990ee9 100644
--- a/client/components/open/forms/components/FirstSubmissionModal.vue
+++ b/client/components/open/forms/components/FirstSubmissionModal.vue
@@ -2,7 +2,7 @@
diff --git a/client/components/open/forms/components/form-components/FormSubmissionSettings.vue b/client/components/open/forms/components/form-components/FormSubmissionSettings.vue
index 1a5214388..41625e1d7 100644
--- a/client/components/open/forms/components/form-components/FormSubmissionSettings.vue
+++ b/client/components/open/forms/components/form-components/FormSubmissionSettings.vue
@@ -129,6 +129,8 @@
-
-
25 ? `${data.field.name.slice(0, 25)}...` : data.field.name
+
+ return node
+
+ }
+
+
+
+
+ static value(node) {
+
+ return {
+
+ field_id: node.getAttribute('mention-field-id'),
+
+ field_name: node.getAttribute('mention-field-name'),
+
+ fallback: node.getAttribute('mention-fallback'),
+
+ }
+
+ }
+
+
+
+
+ static formats() {
+
+ return true
+
+ }
+
+}
+
+
+
+
+export default function registerMentionExtension(Quill) {
+
+ const mentionState = reactive({
+
+ open: false,
+
+ onInsert: null,
+
+ onCancel: null,
+
+ })
+
+
+
+
+ if (!Quill.imports['modules/mention']) {
+
+ Quill.register(MentionBlot)
+
+
+
+
+ class MentionModule {
+
+ constructor(quill, options) {
+
+ this.quill = quill
+
+ this.options = options
+
+ this.setupMentions()
+
+ }
+
+
+
+
+ setupMentions() {
+
+ this.quill.getModule('toolbar').addHandler('mention', () => {
+
+ const range = this.quill.getSelection()
+
+ if (range) {
+
+ mentionState.open = true
+
+ mentionState.onInsert = (mention) => {
+
+ this.insertMention(mention, range.index)
+
+ }
+
+ mentionState.onCancel = () => {
+
+ mentionState.open = false
+
+ }
+
+ }
+
+ })
+
+ }
+
+
+
+
+ insertMention(mention, index) {
+
+ this.quill.insertEmbed(index, 'mention', mention)
+
+ this.quill.insertText(index + 1, ' ')
+
+ this.quill.setSelection(index + 2)
+
+
+
+
+ nextTick(() => {
+
+ mentionState.open = false
+
+ })
+
+ }
+
+ }
+
+
+
+
+ Quill.register('modules/mention', MentionModule)
+
+ }
+
+
+
+
+ return mentionState
+
+}
\ No newline at end of file
From 70804efe0bcacb67bc618cebd9abf5ad770781f3 Mon Sep 17 00:00:00 2001
From: Frank
Date: Wed, 9 Oct 2024 15:56:25 +0100
Subject: [PATCH 02/16] fix lint
---
api/app/Mail/Forms/SubmissionConfirmationMail.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/api/app/Mail/Forms/SubmissionConfirmationMail.php b/api/app/Mail/Forms/SubmissionConfirmationMail.php
index 2b0d249ee..2bc417750 100644
--- a/api/app/Mail/Forms/SubmissionConfirmationMail.php
+++ b/api/app/Mail/Forms/SubmissionConfirmationMail.php
@@ -43,7 +43,7 @@ public function build()
{
$form = $this->event->form;
-
+
return $this
->replyTo($this->getReplyToEmail($form->creator->email))
->from($this->getFromEmail(), $this->integrationData->notification_sender)
From 4640b2630a848263587bff43ddf981c365341bd5 Mon Sep 17 00:00:00 2001
From: Frank
Date: Wed, 9 Oct 2024 16:32:48 +0100
Subject: [PATCH 03/16] add missing changes
---
.../mail/form/confirmation-submission-notification.blade.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/api/resources/views/mail/form/confirmation-submission-notification.blade.php b/api/resources/views/mail/form/confirmation-submission-notification.blade.php
index 871998f30..271779fc0 100644
--- a/api/resources/views/mail/form/confirmation-submission-notification.blade.php
+++ b/api/resources/views/mail/form/confirmation-submission-notification.blade.php
@@ -1,6 +1,6 @@
@component('mail::message', ['noBranding' => $noBranding])
-{!! $integrationData->notification_body !!}
+{!! $notificationBody !!}
@if($form->editable_submissions)
@component('mail::button', ['url' => $form->share_url.'?submission_id='.$submission_id])
From 1b5dc997a312494745a8b703da8b8d695e1de1ed Mon Sep 17 00:00:00 2001
From: Frank
Date: Wed, 9 Oct 2024 16:59:33 +0100
Subject: [PATCH 04/16] fix tests
---
api/app/Mail/Forms/SubmissionConfirmationMail.php | 1 +
1 file changed, 1 insertion(+)
diff --git a/api/app/Mail/Forms/SubmissionConfirmationMail.php b/api/app/Mail/Forms/SubmissionConfirmationMail.php
index 2bc417750..ab51190a0 100644
--- a/api/app/Mail/Forms/SubmissionConfirmationMail.php
+++ b/api/app/Mail/Forms/SubmissionConfirmationMail.php
@@ -49,6 +49,7 @@ public function build()
->from($this->getFromEmail(), $this->integrationData->notification_sender)
->subject($this->getSubject())
->markdown('mail.form.confirmation-submission-notification', [
+ 'notificationBody' => $this->getNotificationBody(),
'fields' => $this->formattedData,
'form' => $form,
'integrationData' => $this->integrationData,
From 3773313c50349641872552dc530111e0defa0291 Mon Sep 17 00:00:00 2001
From: Frank
Date: Thu, 10 Oct 2024 11:02:30 +0100
Subject: [PATCH 05/16] update quilly, fix bugs
---
api/app/Open/MentionParser.php | 50 +++-
api/config/purify.php | 2 +-
.../Unit/Service/Forms/MentionParserTest.php | 196 ++++++++++++++++
.../forms/RichTextAreaInput.client.vue | 180 +++++++-------
.../forms/components/QuillyEditor.vue | 98 ++++++++
client/composables/lib/vForm/Form.js | 2 +-
client/lib/quill/quillMentionExtension.js | 219 +++++++-----------
client/package.json | 2 +-
8 files changed, 517 insertions(+), 232 deletions(-)
create mode 100644 api/tests/Unit/Service/Forms/MentionParserTest.php
create mode 100644 client/components/forms/components/QuillyEditor.vue
diff --git a/api/app/Open/MentionParser.php b/api/app/Open/MentionParser.php
index f520763c5..4b9e4dd7d 100644
--- a/api/app/Open/MentionParser.php
+++ b/api/app/Open/MentionParser.php
@@ -2,6 +2,9 @@
namespace App\Open;
+use DOMDocument;
+use DOMXPath;
+
class MentionParser
{
private $content;
@@ -15,7 +18,48 @@ public function __construct($content, $data)
public function parse()
{
- return $this->replaceMentions();
+ $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()
@@ -42,9 +86,7 @@ private function replaceMentions()
private function getData($fieldId)
{
- $value = collect($this->data)->filter(function ($item) use ($fieldId) {
- return $item['id'] === $fieldId;
- })->first()['value'] ?? null;
+ $value = collect($this->data)->firstWhere('nf_id', $fieldId)['value'] ?? null;
if (is_object($value)) {
return (array) $value;
diff --git a/api/config/purify.php b/api/config/purify.php
index a7c77e8a7..f440c058c 100644
--- a/api/config/purify.php
+++ b/api/config/purify.php
@@ -40,7 +40,7 @@
'configs' => [
'default' => [
- 'HTML.Allowed' => 'h1,h2,b,strong,i,em,a[href|title],ul,ol,li,p,br,span[mention|mention-field-id|mention-field-name|mention-fallback|contenteditable],*[style]',
+ 'HTML.Allowed' => 'h1,h2,b,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',
diff --git a/api/tests/Unit/Service/Forms/MentionParserTest.php b/api/tests/Unit/Service/Forms/MentionParserTest.php
new file mode 100644
index 000000000..79ed8667c
--- /dev/null
+++ b/api/tests/Unit/Service/Forms/MentionParserTest.php
@@ -0,0 +1,196 @@
+Hello Placeholder
';
+
+ $data = [['nf_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 = [
+
+ ['nf_id' => '123', 'value' => 'John'],
+
+ ['nf_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 = [['nf_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 = [['nf_id' => '123', 'value' => 'World']];
+
+
+
+
+ $parser = new MentionParser($content, $data);
+
+ $result = $parser->parse();
+
+
+
+
+ expect($result)->toBe('');
+
+});
+
+
+
+
+test('it handles UTF-8 characters', function () {
+
+ $content = 'こんにちは Placeholder
';
+
+ $data = [['nf_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 = [['nf_id' => '123', 'value' => 'replaced text']];
+
+
+
+
+ $parser = new MentionParser($content, $data);
+
+ $result = $parser->parse();
+
+
+
+
+ expect($result)->toBe('some text replaced text dewde');
+
+});
\ No newline at end of file
diff --git a/client/components/forms/RichTextAreaInput.client.vue b/client/components/forms/RichTextAreaInput.client.vue
index 48f0da479..72ba8a538 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
+ }"
+ >
+
+
@@ -38,7 +39,7 @@
@@ -46,81 +47,63 @@
-
+
\ 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/composables/lib/vForm/Form.js b/client/composables/lib/vForm/Form.js
index b8e3c384c..cab2744cc 100644
--- a/client/composables/lib/vForm/Form.js
+++ b/client/composables/lib/vForm/Form.js
@@ -89,7 +89,7 @@ class Form {
Object.keys(this)
.filter((key) => !Form.ignore.includes(key))
.forEach((key) => {
- this[key] = JSON.parse(JSON.stringify(this.originalData[key]))
+ this[key] = cloneDeep(this.originalData[key])
})
}
diff --git a/client/lib/quill/quillMentionExtension.js b/client/lib/quill/quillMentionExtension.js
index 853fb10a8..ffb28d8d6 100644
--- a/client/lib/quill/quillMentionExtension.js
+++ b/client/lib/quill/quillMentionExtension.js
@@ -1,177 +1,130 @@
+import { reactive } from 'vue'
import Quill from 'quill'
+const Inline = Quill.import('blots/inline')
-import { reactive, nextTick } from 'vue'
-
-
-
-
-const Embed = Quill.import('blots/embed')
-
-
-
-
-class MentionBlot extends Embed {
-
- static blotName = "mention"
-
- static tagName = "span"
-
-
-
-
- static create(data) {
-
- const node = super.create()
-
- node.setAttribute('mention-field-id', data.field.id)
-
- node.setAttribute('mention-field-name', data.field.name)
-
- node.setAttribute('mention-fallback', data.fallback)
-
- node.setAttribute('contenteditable', 'false')
-
- node.setAttribute('mention', true)
-
-
-
-
- node.textContent = data.field.name.length > 25 ? `${data.field.name.slice(0, 25)}...` : data.field.name
-
- return node
-
- }
-
-
-
-
- static value(node) {
-
- return {
-
- field_id: node.getAttribute('mention-field-id'),
-
- field_name: node.getAttribute('mention-field-name'),
-
- fallback: node.getAttribute('mention-fallback'),
-
+export default function registerMentionExtension(Quill) {
+ class MentionBlot extends Inline {
+ static blotName = 'mention'
+ static tagName = 'SPAN'
+
+ static create(data) {
+ let node = super.create()
+ MentionBlot.setAttributes(node, data)
+ return node
}
- }
+ static setAttributes(node, data) {
+ node.setAttribute('contenteditable', 'false')
+ node.setAttribute('mention', 'true')
+
+ if (data && typeof data === 'object') {
+ node.setAttribute('mention-field-id', data.field?.nf_id || '')
+ node.setAttribute('mention-field-name', data.field?.name || '')
+ node.setAttribute('mention-fallback', data.fallback || '')
+ node.textContent = data.field?.name || ''
+ } else {
+ // Handle case where data is not an object (e.g., during undo)
+ node.textContent = data || ''
+ }
+ }
+ static formats(domNode) {
+ return {
+ 'mention-field-id': domNode.getAttribute('mention-field-id') || '',
+ 'mention-field-name': domNode.getAttribute('mention-field-name') || '',
+ 'mention-fallback': domNode.getAttribute('mention-fallback') || ''
+ }
+ }
+ format(name, value) {
+ if (name === 'mention' && value) {
+ MentionBlot.setAttributes(this.domNode, value)
+ } else {
+ super.format(name, value)
+ }
+ }
+ formats() {
+ let formats = super.formats()
+ formats['mention'] = MentionBlot.formats(this.domNode)
+ return formats
+ }
- static formats() {
+ static value(domNode) {
+ return {
+ field: {
+ nf_id: domNode.getAttribute('mention-field-id') || '',
+ name: domNode.getAttribute('mention-field-name') || ''
+ },
+ fallback: domNode.getAttribute('mention-fallback') || ''
+ }
+ }
- return true
+ // Override attach to ensure contenteditable is always set
+ attach() {
+ super.attach()
+ this.domNode.setAttribute('contenteditable', 'false')
+ }
+ length() {
+ return 1
+ }
}
-}
-
-
-
-
-export default function registerMentionExtension(Quill) {
+ Quill.register(MentionBlot)
const mentionState = reactive({
-
open: false,
-
onInsert: null,
-
onCancel: null,
-
})
+ class MentionModule {
+ constructor(quill, options) {
+ this.quill = quill
+ this.options = options
+ this.setupMentions()
+ }
-
- if (!Quill.imports['modules/mention']) {
-
- Quill.register(MentionBlot)
-
-
-
-
- class MentionModule {
-
- constructor(quill, options) {
-
- this.quill = quill
-
- this.options = options
-
- this.setupMentions()
-
- }
-
-
-
-
- setupMentions() {
-
- this.quill.getModule('toolbar').addHandler('mention', () => {
-
+ setupMentions() {
+ const toolbar = this.quill.getModule('toolbar')
+ if (toolbar) {
+ toolbar.addHandler('mention', () => {
const range = this.quill.getSelection()
-
if (range) {
-
mentionState.open = true
-
mentionState.onInsert = (mention) => {
-
this.insertMention(mention, range.index)
-
}
-
mentionState.onCancel = () => {
-
mentionState.open = false
-
}
-
}
-
})
-
}
-
-
-
-
- insertMention(mention, index) {
-
- this.quill.insertEmbed(index, 'mention', mention)
-
- this.quill.insertText(index + 1, ' ')
-
- this.quill.setSelection(index + 2)
-
-
-
-
- nextTick(() => {
-
- mentionState.open = false
-
- })
-
- }
-
}
+ insertMention(mention, index) {
+ mentionState.open = false
+ // Insert the mention
+ this.quill.insertEmbed(index, 'mention', mention, Quill.sources.USER)
+ // Calculate the length of the inserted mention
+ const mentionLength = this.quill.getLength() - index
- Quill.register('modules/mention', MentionModule)
+ nextTick(() => {
+ // Focus the editor
+ this.quill.focus()
+ // Set the selection after the mention
+ this.quill.setSelection(index + mentionLength, 0, Quill.sources.SILENT)
+ })
+ }
}
-
-
+ Quill.register('modules/mention', MentionModule)
return mentionState
-
}
\ No newline at end of file
diff --git a/client/package.json b/client/package.json
index d5ca746c2..ba1c30512 100644
--- a/client/package.json
+++ b/client/package.json
@@ -59,6 +59,7 @@
"prismjs": "^1.24.1",
"qrcode": "^1.5.1",
"query-builder-vue-3": "^1.0.1",
+ "quill": "^2.0.2",
"tailwind-merge": "^2.3.0",
"tinymotion": "^0.2.0",
"v-calendar": "^3.1.2",
@@ -70,7 +71,6 @@
"vue-json-pretty": "^2.4.0",
"vue-notion": "^3.0.0-beta.1",
"vue-signature-pad": "^3.0.2",
- "vue3-editor": "^0.1.1",
"vuedraggable": "next",
"webcam-easy": "^1.1.1"
},
From f8c2b30a8e09cbd5fddee530ad1a44fe8e46ce71 Mon Sep 17 00:00:00 2001
From: Frank
Date: Thu, 10 Oct 2024 11:14:20 +0100
Subject: [PATCH 06/16] fix lint
---
api/tests/Unit/Service/Forms/MentionParserTest.php | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/api/tests/Unit/Service/Forms/MentionParserTest.php b/api/tests/Unit/Service/Forms/MentionParserTest.php
index 79ed8667c..908f0bc1b 100644
--- a/api/tests/Unit/Service/Forms/MentionParserTest.php
+++ b/api/tests/Unit/Service/Forms/MentionParserTest.php
@@ -5,9 +5,6 @@
use App\Open\MentionParser;
-
-
-
test('it replaces mention elements with their corresponding values', function () {
$content = 'Hello Placeholder
';
@@ -193,4 +190,4 @@
expect($result)->toBe('some text replaced text dewde');
-});
\ No newline at end of file
+});
From 1e96b468c70258ed077b0c76e64f573a11140f6e Mon Sep 17 00:00:00 2001
From: Frank
Date: Thu, 10 Oct 2024 12:07:13 +0100
Subject: [PATCH 07/16] apply fixes
---
client/components/forms/MentionInput.vue | 1 +
.../forms/RichTextAreaInput.client.vue | 3 +-
client/composables/useParseMention.js | 39 +++++++++++++++++++
3 files changed, 41 insertions(+), 2 deletions(-)
create mode 100644 client/composables/useParseMention.js
diff --git a/client/components/forms/MentionInput.vue b/client/components/forms/MentionInput.vue
index fd70c464b..3dbb8369f 100644
--- a/client/components/forms/MentionInput.vue
+++ b/client/components/forms/MentionInput.vue
@@ -27,6 +27,7 @@
},
'pr-12'
]"
+ :placeholder="placeholder"
@input="onInput"
/>
{
content: '@';
font-size: 16px;
}
-.rich-editor {
+.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;
- cursor: default;
}
}
\ No newline at end of file
diff --git a/client/composables/useParseMention.js b/client/composables/useParseMention.js
new file mode 100644
index 000000000..59494d307
--- /dev/null
+++ b/client/composables/useParseMention.js
@@ -0,0 +1,39 @@
+import { FormSubmissionFormatter } from '~/components/forms/components/FormSubmissionFormatter'
+
+export function useParseMention(content, mentionsAllowed, form, formData) {
+ if (!mentionsAllowed || !form || !formData) {
+ return content
+ }
+
+ const formatter = new FormSubmissionFormatter(form, formData).setOutputStringsOnly()
+ const formattedData = formatter.getFormattedData()
+
+ // Create a new DOMParser
+ const parser = new DOMParser()
+ // Parse the content as HTML
+ const doc = parser.parseFromString(content, 'text/html')
+
+ // Find all elements with mention attribute
+ const mentionElements = doc.querySelectorAll('[mention]')
+
+ mentionElements.forEach(element => {
+ const fieldId = element.getAttribute('mention-field-id')
+ const fallback = element.getAttribute('mention-fallback')
+ const value = formattedData[fieldId]
+
+ if (value) {
+ if (Array.isArray(value)) {
+ element.textContent = value.join(', ')
+ } else {
+ element.textContent = value
+ }
+ } else if (fallback) {
+ element.textContent = fallback
+ } else {
+ element.remove()
+ }
+ })
+
+ // Return the processed HTML content
+ return doc.body.innerHTML
+}
\ No newline at end of file
From 92dc063b186f8edccce351f2da8eff0655095915 Mon Sep 17 00:00:00 2001
From: Frank
Date: Thu, 10 Oct 2024 15:25:06 +0100
Subject: [PATCH 08/16] apply fixes
---
client/components/forms/TextBlock.vue | 22 +------------------
.../forms/components/MentionDropdown.vue | 2 +-
2 files changed, 2 insertions(+), 22 deletions(-)
diff --git a/client/components/forms/TextBlock.vue b/client/components/forms/TextBlock.vue
index 71b3218dc..8a2c7c641 100644
--- a/client/components/forms/TextBlock.vue
+++ b/client/components/forms/TextBlock.vue
@@ -5,7 +5,6 @@
\ No newline at end of file
diff --git a/client/components/forms/components/MentionDropdown.vue b/client/components/forms/components/MentionDropdown.vue
index 49dcd0398..1e7b116cc 100644
--- a/client/components/forms/components/MentionDropdown.vue
+++ b/client/components/forms/components/MentionDropdown.vue
@@ -84,7 +84,7 @@
const selectedField = ref(null)
const fallbackValue = ref('')
const filteredMentions = computed(() => {
- return props.mentions.filter(mention => blocksTypes[mention.type].is_input)
+ return props.mentions.filter(mention => blocksTypes[mention.type]?.is_input ?? false)
})
function selectField(field, insert = false) {
selectedField.value = {...field}
From 5ffec25000d2fe76b3b4b496857f7f1412502cb0 Mon Sep 17 00:00:00 2001
From: Chirag Chhatrala
Date: Thu, 17 Oct 2024 13:51:44 +0530
Subject: [PATCH 09/16] Fix MentionParser
---
api/app/Open/MentionParser.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/api/app/Open/MentionParser.php b/api/app/Open/MentionParser.php
index 4b9e4dd7d..01c8e3c80 100644
--- a/api/app/Open/MentionParser.php
+++ b/api/app/Open/MentionParser.php
@@ -86,7 +86,7 @@ private function replaceMentions()
private function getData($fieldId)
{
- $value = collect($this->data)->firstWhere('nf_id', $fieldId)['value'] ?? null;
+ $value = collect($this->data)->firstWhere('id', $fieldId)['value'] ?? null;
if (is_object($value)) {
return (array) $value;
From 5acb3ce0ee661efc5b8b0b548d6cc76fdd8772ea Mon Sep 17 00:00:00 2001
From: Chirag Chhatrala
Date: Thu, 17 Oct 2024 14:10:54 +0530
Subject: [PATCH 10/16] Apply Mentions everywhere
---
.../Commands/EmailNotificationMigration.php | 93 +++++++++++
.../Forms/PublicFormController.php | 14 +-
api/app/Http/Requests/UserFormRequest.php | 2 +-
.../Handlers/DiscordIntegration.php | 10 +-
.../Handlers/EmailIntegration.php | 52 +++++--
.../Handlers/SlackIntegration.php | 10 +-
.../SubmissionConfirmationIntegration.php | 114 --------------
.../Mail/Forms/SubmissionConfirmationMail.php | 97 ------------
...fication.php => FormEmailNotification.php} | 64 +++++---
api/resources/data/forms/integrations.json | 7 -
...irmation-submission-notification.blade.php | 34 -----
.../mail/form/email-notification.blade.php | 29 ++++
.../form/submission-notification.blade.php | 28 ----
.../Feature/Forms/ConfirmationEmailTest.php | 144 ------------------
api/tests/Feature/Forms/CustomSmtpTest.php | 39 ++---
.../Feature/Forms/EmailNotificationTest.php | 100 ++++++++++++
.../Forms/FormIntegrationEventTest.php | 9 +-
.../Feature/Forms/FormIntegrationTest.php | 9 +-
.../Unit/EmailNotificationMigrationTest.php | 80 ++++++++++
client/components/forms/MentionInput.vue | 111 +++++++-------
client/components/global/Modal.vue | 4 +-
.../FormSubmissionSettings.vue | 3 +-
.../open/integrations/DiscordIntegration.vue | 5 +-
.../open/integrations/EmailIntegration.vue | 76 ++++++---
.../open/integrations/SlackIntegration.vue | 5 +-
.../SubmissionConfirmationIntegration.vue | 110 -------------
.../NotificationsMessageActions.vue | 16 +-
.../pages/pricing/SubscriptionModal.vue | 1 +
client/data/forms/integrations.json | 7 -
29 files changed, 594 insertions(+), 679 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
rename api/app/Notifications/Forms/{FormSubmissionNotification.php => FormEmailNotification.php} (56%)
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
delete mode 100644 client/components/open/integrations/SubmissionConfirmationIntegration.vue
diff --git a/api/app/Console/Commands/EmailNotificationMigration.php b/api/app/Console/Commands/EmailNotificationMigration.php
new file mode 100644
index 000000000..599f99926
--- /dev/null
+++ b/api/app/Console/Commands/EmailNotificationMigration.php
@@ -0,0 +1,93 @@
+where('integration_id', 'email')
+ ->orWhere('integration_id', 'submission_confirmation');
+ })->chunk(
+ 100,
+ function ($integrations) {
+ foreach ($integrations as $integration) {
+ $this->line('Process For Form: ' . $integration->form_id . ' - ' . $integration->integration_id . ' - ' . $integration->id);
+
+ $this->updateIntegration($integration);
+ }
+ }
+ );
+
+ $this->line('Migration Done');
+ }
+
+ public function updateIntegration(FormIntegration $integration)
+ {
+ $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_id),
+ '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($formId)
+ {
+ $emailField = $this->getRespondentEmail($formId);
+ return $emailField ? '' . $emailField['name'] . '' : '';
+ }
+
+ private function getRespondentEmail($formId)
+ {
+ $form = Form::find($formId);
+ $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 67e1134a1..bfe2242ba 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;
@@ -101,12 +102,21 @@ public function answer(AnswerFormRequest $request)
StoreFormSubmissionJob::dispatch($form, $submissionData, $completionTime);
}
+ // Parse redirect URL
+ $formattedData = collect($submissionData)->map(function ($value, $key) {
+ return ['id' => $key, 'value' => $value];
+ })->values()->all();
+ $redirectUrl = ($request->form->redirect_url) ? (new MentionParser($request->form->redirect_url, $formattedData))->parse() : null;
+ if ($redirectUrl && !filter_var($redirectUrl, FILTER_VALIDATE_URL)) {
+ $redirectUrl = null;
+ }
+
return $this->success(array_merge([
'message' => 'Form submission saved.',
'submission_id' => $submissionId,
- ], $request->form->is_pro && $request->form->redirect_url ? [
+ ], $request->form->is_pro && $redirectUrl ? [
'redirect' => true,
- 'redirect_url' => $request->form->redirect_url,
+ 'redirect_url' => $redirectUrl,
] : [
'redirect' => false,
]));
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..11ed071e7 100644
--- a/api/app/Integrations/Handlers/EmailIntegration.php
+++ b/api/app/Integrations/Handlers/EmailIntegration.php
@@ -2,24 +2,50 @@
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',
+ '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 +54,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 a556e13c5..000000000
--- a/api/app/Integrations/Handlers/SubmissionConfirmationIntegration.php
+++ /dev/null
@@ -1,114 +0,0 @@
- [
- 'required',
- 'boolean',
- function ($attribute, $value, $fail) {
- if ($value !== true) {
- $fail('Need at least 1 email field.');
- }
- },
- ],
- 'confirmation_reply_to' => '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_subject' => Purify::clean($data['notification_subject'] ?? ''),
- '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 ab51190a0..000000000
--- a/api/app/Mail/Forms/SubmissionConfirmationMail.php
+++ /dev/null
@@ -1,97 +0,0 @@
-form, $event->data))
- ->createLinks()
- ->outputStringsOnly()
- ->useSignedUrlForFiles();
-
- $this->formattedData = $formatter->getFieldsWithValue();
- }
-
- /**
- * Build the message.
- *
- * @return $this
- */
- public function build()
- {
- $form = $this->event->form;
-
-
- return $this
- ->replyTo($this->getReplyToEmail($form->creator->email))
- ->from($this->getFromEmail(), $this->integrationData->notification_sender)
- ->subject($this->getSubject())
- ->markdown('mail.form.confirmation-submission-notification', [
- 'notificationBody' => $this->getNotificationBody(),
- 'fields' => $this->formattedData,
- '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) {
- $parser = new MentionParser($replyTo, $this->formattedData);
- $parsedReplyTo = $parser->parse();
- if ($parsedReplyTo && filter_var($parsedReplyTo, FILTER_VALIDATE_EMAIL)) {
- return $parsedReplyTo;
- }
- }
- return $default;
- }
-
- private function getSubject()
- {
- $parser = new MentionParser($this->integrationData->notification_subject, $this->formattedData);
- return $parser->parse();
- }
-
- private function getNotificationBody()
- {
- $parser = new MentionParser($this->integrationData->notification_body, $this->formattedData);
- return $parser->parse();
- }
-}
diff --git a/api/app/Notifications/Forms/FormSubmissionNotification.php b/api/app/Notifications/Forms/FormEmailNotification.php
similarity index 56%
rename from api/app/Notifications/Forms/FormSubmissionNotification.php
rename to api/app/Notifications/Forms/FormEmailNotification.php
index 342098df8..ad5d97a72 100644
--- a/api/app/Notifications/Forms/FormSubmissionNotification.php
+++ b/api/app/Notifications/Forms/FormEmailNotification.php
@@ -3,19 +3,22 @@
namespace App\Notifications\Forms;
use App\Events\Forms\FormSubmitted;
+use App\Open\MentionParser;
use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
+use Vinkla\Hashids\Facades\Hashids;
-class FormSubmissionNotification extends Notification implements ShouldQueue
+class FormEmailNotification extends Notification implements ShouldQueue
{
use Queueable;
public FormSubmitted $event;
- private $mailer;
+ public $mailer;
+ private $formattedData;
/**
* Create a new notification instance.
@@ -26,12 +29,21 @@ public function __construct(FormSubmitted $event, private $integrationData, stri
{
$this->event = $event;
$this->mailer = $mailer;
+
+ $formatter = (new FormSubmissionFormatter($event->form, $event->data))
+ ->createLinks()
+ ->outputStringsOnly()
+ ->useSignedUrlForFiles();
+ if ($this->integrationData->include_hidden_fields_submission_data ?? false) {
+ $formatter->showHiddenFields();
+ }
+ $this->formattedData = $formatter->getFieldsWithValue();
}
/**
* Get the notification's delivery channels.
*
- * @param mixed $notifiable
+ * @param mixed $notifiable
* @return array
*/
public function via($notifiable)
@@ -42,25 +54,23 @@ public function via($notifiable)
/**
* Get the mail representation of the notification.
*
- * @param mixed $notifiable
+ * @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(),
+ ->from($this->getFromEmail(), $this->integrationData->sender_name ?? config('app.name'))
+ ->subject($this->getSubject())
+ ->markdown('mail.form.email-notification', [
+ 'emailContent' => $this->getEmailContent(),
+ 'fields' => $this->formattedData,
'form' => $this->event->form,
+ 'integrationData' => $this->integrationData,
+ 'noBranding' => $this->event->form->no_branding,
+ 'submission_id' => (isset($this->event->data['submission_id']) && $this->event->data['submission_id']) ? Hashids::encode($this->event->data['submission_id']) : null,
]);
}
@@ -69,6 +79,7 @@ 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();
@@ -76,14 +87,25 @@ private function getFromEmail()
private function getReplyToEmail($default)
{
- $replyTo = $this->integrationData->notification_reply_to ?? null;
- if ($replyTo && $this->validateEmail($replyTo)) {
- return $replyTo;
+ $replyTo = $this->integrationData->reply_to ?? null;
+
+ if ($replyTo) {
+ $parser = new MentionParser($replyTo, $this->formattedData);
+ $parsedReplyTo = $parser->parse();
+ if ($parsedReplyTo && $this->validateEmail($parsedReplyTo)) {
+ return $parsedReplyTo;
+ }
}
return $this->getRespondentEmail() ?? $default;
}
+ private function getSubject()
+ {
+ $parser = new MentionParser($this->integrationData->subject, $this->formattedData);
+ return $parser->parse();
+ }
+
private function getRespondentEmail()
{
// Make sure we only have one email field in the form
@@ -106,8 +128,14 @@ private function getRespondentEmail()
return null;
}
+ private function getEmailContent()
+ {
+ $parser = new MentionParser($this->integrationData->email_content, $this->formattedData);
+ return $parser->parse();
+ }
+
public static function validateEmail($email): bool
{
- return (bool) filter_var($email, FILTER_VALIDATE_EMAIL);
+ return (bool)filter_var($email, FILTER_VALIDATE_EMAIL);
}
}
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 271779fc0..000000000
--- a/api/resources/views/mail/form/confirmation-submission-notification.blade.php
+++ /dev/null
@@ -1,34 +0,0 @@
-@component('mail::message', ['noBranding' => $noBranding])
-
-{!! $notificationBody !!}
-
-@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..ad189ee23
--- /dev/null
+++ b/api/resources/views/mail/form/email-notification.blade.php
@@ -0,0 +1,29 @@
+@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)
+Here is the answer:
+
+@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..b66e19ff2
--- /dev/null
+++ b/api/tests/Feature/Forms/EmailNotificationTest.php
@@ -0,0 +1,100 @@
+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';
+ }
+ );
+});
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/client/components/forms/MentionInput.vue b/client/components/forms/MentionInput.vue
index 3dbb8369f..85a56e3f5 100644
--- a/client/components/forms/MentionInput.vue
+++ b/client/components/forms/MentionInput.vue
@@ -1,59 +1,59 @@
-
-
-
-
+
+
+
+
-
+
-
+
-
-
-
+
+
+
-
-
-
-
-
+
+
+
+
+
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 15fd02fb2..000000000
--- a/client/components/open/integrations/SubmissionConfirmationIntegration.vue
+++ /dev/null
@@ -1,110 +0,0 @@
-
-
-
- {{ emailSubmissionConfirmationHelp }}
-
-
-
- You can
- use our custom SMTP feature
- to send emails from your own domain.
-
-
-
-
-
-
-
-
-
-
-
-
-
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 @@
+
{
if (this.compVal[keyname] === undefined) {
- this.compVal[keyname] = true
+ if (keyname === 'message') {
+ this.compVal[keyname] = 'New form submission'
+ } else {
+ this.compVal[keyname] = true
+ }
}
})
},
diff --git a/client/components/pages/pricing/SubscriptionModal.vue b/client/components/pages/pricing/SubscriptionModal.vue
index ce9dda91d..fcd2480ee 100644
--- a/client/components/pages/pricing/SubscriptionModal.vue
+++ b/client/components/pages/pricing/SubscriptionModal.vue
@@ -4,6 +4,7 @@
compact-header
max-width="screen-lg"
backdrop-blur
+ class="z-50"
@close="subscriptionModalStore.closeModal()"
>
diff --git a/client/data/forms/integrations.json b/client/data/forms/integrations.json
index 7eda1d009..a61ff56db 100644
--- a/client/data/forms/integrations.json
+++ b/client/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",
From 0213f585d308c8614e1095e128eb456a5984f12c Mon Sep 17 00:00:00 2001
From: Chirag Chhatrala
Date: Thu, 17 Oct 2024 14:11:14 +0530
Subject: [PATCH 11/16] Fix MentionParserTest
---
.../Unit/Service/Forms/MentionParserTest.php | 121 +-----------------
1 file changed, 7 insertions(+), 114 deletions(-)
diff --git a/api/tests/Unit/Service/Forms/MentionParserTest.php b/api/tests/Unit/Service/Forms/MentionParserTest.php
index 908f0bc1b..182839901 100644
--- a/api/tests/Unit/Service/Forms/MentionParserTest.php
+++ b/api/tests/Unit/Service/Forms/MentionParserTest.php
@@ -1,193 +1,86 @@
Hello Placeholder';
-
- $data = [['nf_id' => '123', 'value' => 'World']];
-
-
-
+ $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 = [
-
- ['nf_id' => '123', 'value' => 'John'],
-
- ['nf_id' => '456', 'value' => 30],
-
+ ['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 = [['nf_id' => '123', 'value' => ['PHP', 'Laravel', 'Testing']]];
-
-
-
+ $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 = [['nf_id' => '123', 'value' => 'World']];
-
-
-
+ $data = [['id' => '123', 'value' => 'World']];
$parser = new MentionParser($content, $data);
-
$result = $parser->parse();
-
-
-
expect($result)->toBe('');
-
});
-
-
-
test('it handles UTF-8 characters', function () {
-
$content = 'こんにちは Placeholder
';
-
- $data = [['nf_id' => '123', 'value' => '世界']];
-
-
-
+ $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 = [['nf_id' => '123', 'value' => 'replaced text']];
-
-
-
+ $data = [['id' => '123', 'value' => 'replaced text']];
$parser = new MentionParser($content, $data);
-
$result = $parser->parse();
-
-
-
expect($result)->toBe('some text replaced text dewde');
-
});
From 0882c1a9a07b3b600d76963f7ced79d954ec5b98 Mon Sep 17 00:00:00 2001
From: Julien Nahum
Date: Mon, 21 Oct 2024 20:00:17 +0200
Subject: [PATCH 12/16] Small refactoring
---
.../Forms/PublicFormController.php | 20 ++++++++++++-------
.../Forms/FormEmailNotification.php | 6 +++---
2 files changed, 16 insertions(+), 10 deletions(-)
diff --git a/api/app/Http/Controllers/Forms/PublicFormController.php b/api/app/Http/Controllers/Forms/PublicFormController.php
index bfe2242ba..b7c0f0ab8 100644
--- a/api/app/Http/Controllers/Forms/PublicFormController.php
+++ b/api/app/Http/Controllers/Forms/PublicFormController.php
@@ -102,24 +102,30 @@ public function answer(AnswerFormRequest $request)
StoreFormSubmissionJob::dispatch($form, $submissionData, $completionTime);
}
- // Parse redirect URL
+ return $this->success(array_merge([
+ 'message' => 'Form submission saved.',
+ 'submission_id' => $submissionId,
+ ], $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 = ($request->form->redirect_url) ? (new MentionParser($request->form->redirect_url, $formattedData))->parse() : null;
+
+ $redirectUrl = ($form->redirect_url) ? (new MentionParser($form->redirect_url, $formattedData))->parse() : null;
+
if ($redirectUrl && !filter_var($redirectUrl, FILTER_VALIDATE_URL)) {
$redirectUrl = null;
}
- return $this->success(array_merge([
- 'message' => 'Form submission saved.',
- 'submission_id' => $submissionId,
- ], $request->form->is_pro && $redirectUrl ? [
+ return $form->is_pro && $redirectUrl ? [
'redirect' => true,
'redirect_url' => $redirectUrl,
] : [
'redirect' => false,
- ]));
+ ];
}
public function fetchSubmission(Request $request, string $slug, string $submissionId)
diff --git a/api/app/Notifications/Forms/FormEmailNotification.php b/api/app/Notifications/Forms/FormEmailNotification.php
index ad5d97a72..20cf742c7 100644
--- a/api/app/Notifications/Forms/FormEmailNotification.php
+++ b/api/app/Notifications/Forms/FormEmailNotification.php
@@ -17,7 +17,7 @@ class FormEmailNotification extends Notification implements ShouldQueue
use Queueable;
public FormSubmitted $event;
- public $mailer;
+ public string $mailer;
private $formattedData;
/**
@@ -102,7 +102,7 @@ private function getReplyToEmail($default)
private function getSubject()
{
- $parser = new MentionParser($this->integrationData->subject, $this->formattedData);
+ $parser = new MentionParser($this->integrationData->subject ?? 'New form submission', $this->formattedData);
return $parser->parse();
}
@@ -130,7 +130,7 @@ private function getRespondentEmail()
private function getEmailContent()
{
- $parser = new MentionParser($this->integrationData->email_content, $this->formattedData);
+ $parser = new MentionParser($this->integrationData->email_content ?? '', $this->formattedData);
return $parser->parse();
}
From d59809bc2be2964625aadcb907ae80f0c355308f Mon Sep 17 00:00:00 2001
From: Julien Nahum
Date: Mon, 21 Oct 2024 20:30:55 +0200
Subject: [PATCH 13/16] Fixing quill import issues
---
.../forms/RichTextAreaInput.client.vue | 14 +--
.../components/IntegrationWrapper.vue | 21 ++--
client/nuxt.config.ts | 5 +-
client/package-lock.json | 113 +++++++-----------
client/package.json | 4 -
5 files changed, 61 insertions(+), 96 deletions(-)
diff --git a/client/components/forms/RichTextAreaInput.client.vue b/client/components/forms/RichTextAreaInput.client.vue
index 4f398b6f4..462ac10d8 100644
--- a/client/components/forms/RichTextAreaInput.client.vue
+++ b/client/components/forms/RichTextAreaInput.client.vue
@@ -4,7 +4,7 @@
-
-
@@ -74,11 +73,11 @@ const { compVal, inputStyle, hasError, inputWrapperProps } = useFormInput(props,
const editor = ref(null)
const mentionState = ref(null)
// Move the mention extension registration to onMounted
-onMounted(() => {
- if (props.enableMentions && !Quill.imports['blots/mention']) {
- mentionState.value = registerMentionExtension(Quill)
- }
-})
+
+if (props.enableMentions && !Quill.imports['blots/mention']) {
+ mentionState.value = registerMentionExtension(Quill)
+}
+
const quillOptions = computed(() => {
const defaultOptions = {
theme: 'snow',
@@ -147,6 +146,7 @@ const quillOptions = computed(() => {
}
.ql-mention {
padding-top: 0px !important;
+ margin-top: -5px !important;
}
.ql-mention::after {
content: '@';
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/nuxt.config.ts b/client/nuxt.config.ts
index 81c620c17..b780de9f9 100644
--- a/client/nuxt.config.ts
+++ b/client/nuxt.config.ts
@@ -12,7 +12,7 @@ export default defineNuxtConfig({
'@pinia/nuxt',
'@vueuse/nuxt',
'@vueuse/motion/nuxt',
- '@nuxtjs/sitemap',
+ // '@nuxtjs/sitemap',
'@nuxt/ui',
'nuxt-utm',
...process.env.NUXT_PUBLIC_GTM_CODE ? ['@zadigetvoltaire/nuxt-gtm'] : [],
@@ -80,9 +80,6 @@ export default defineNuxtConfig({
fallback: 'light',
classPrefix: '',
},
- ui: {
- icons: ['heroicons', 'material-symbols']
- },
sitemap,
runtimeConfig,
gtm
diff --git a/client/package-lock.json b/client/package-lock.json
index 45a621394..43b9fb410 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -35,6 +35,7 @@
"prismjs": "^1.29.0",
"qrcode": "^1.5.4",
"query-builder-vue-3": "^1.0.1",
+ "quill": "^2.0.2",
"tailwind-merge": "^2.5.4",
"tinymotion": "^0.2.0",
"v-calendar": "^3.1.2",
@@ -46,8 +47,7 @@
"vue-json-pretty": "^2.4.0",
"vue-notion": "^3.0.0",
"vue-signature-pad": "^3.0.2",
- "vue3-editor": "^0.1.1",
- "vuedraggable": "^4.1.0",
+ "vuedraggable": "next",
"webcam-easy": "^1.1.1"
},
"devDependencies": {
@@ -6219,15 +6219,6 @@
"wrap-ansi": "^6.2.0"
}
},
- "node_modules/clone": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
- "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
- "license": "MIT",
- "engines": {
- "node": ">=0.8"
- }
- },
"node_modules/clone-deep": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
@@ -6458,17 +6449,6 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
- "node_modules/core-js": {
- "version": "3.38.1",
- "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.38.1.tgz",
- "integrity": "sha512-OP35aUorbU3Zvlx7pjsFdu1rGNnD4pgw/CWoYzRY3t2EzoVT7shKHY1dlAy3f41cGIO7ZDPQimhGFTlEYkG/Hw==",
- "hasInstallScript": true,
- "license": "MIT",
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/core-js"
- }
- },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -7679,9 +7659,9 @@
}
},
"node_modules/eventemitter3": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
- "integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/events": {
@@ -7723,12 +7703,6 @@
"dev": true,
"license": "Apache-2.0"
},
- "node_modules/extend": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
- "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
- "license": "MIT"
- },
"node_modules/externality": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/externality/-/externality-1.0.2.tgz",
@@ -7752,7 +7726,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
- "dev": true,
"license": "Apache-2.0"
},
"node_modules/fast-fifo": {
@@ -9574,12 +9547,24 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
+ "node_modules/lodash-es": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
+ "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
+ "license": "MIT"
+ },
"node_modules/lodash.castarray": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
"integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
"license": "MIT"
},
+ "node_modules/lodash.clonedeep": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+ "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -9592,6 +9577,12 @@
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
+ "node_modules/lodash.isequal": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+ "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
@@ -11867,9 +11858,9 @@
}
},
"node_modules/parchment": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
- "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
+ "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
"license": "BSD-3-Clause"
},
"node_modules/parent-module": {
@@ -13142,39 +13133,34 @@
"license": "MIT"
},
"node_modules/quill": {
- "version": "1.3.7",
- "resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
- "integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.2.tgz",
+ "integrity": "sha512-QfazNrhMakEdRG57IoYFwffUIr04LWJxbS/ZkidRFXYCQt63c1gK6Z7IHUXMx/Vh25WgPBU42oBaNzQ0K1R/xw==",
"license": "BSD-3-Clause",
"dependencies": {
- "clone": "^2.1.1",
- "deep-equal": "^1.0.1",
- "eventemitter3": "^2.0.3",
- "extend": "^3.0.2",
- "parchment": "^1.1.4",
- "quill-delta": "^3.6.2"
+ "eventemitter3": "^5.0.1",
+ "lodash-es": "^4.17.21",
+ "parchment": "^3.0.0",
+ "quill-delta": "^5.1.0"
+ },
+ "engines": {
+ "npm": ">=8.2.3"
}
},
"node_modules/quill-delta": {
- "version": "3.6.3",
- "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
- "integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
+ "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
"license": "MIT",
"dependencies": {
- "deep-equal": "^1.0.1",
- "extend": "^3.0.2",
- "fast-diff": "1.1.2"
+ "fast-diff": "^1.3.0",
+ "lodash.clonedeep": "^4.5.0",
+ "lodash.isequal": "^4.5.0"
},
"engines": {
- "node": ">=0.10"
+ "node": ">= 12.0.0"
}
},
- "node_modules/quill-delta/node_modules/fast-diff": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
- "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
- "license": "Apache-2.0"
- },
"node_modules/radix3": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz",
@@ -16849,19 +16835,6 @@
"vue": "^3.2.0"
}
},
- "node_modules/vue3-editor": {
- "version": "0.1.1",
- "resolved": "https://registry.npmjs.org/vue3-editor/-/vue3-editor-0.1.1.tgz",
- "integrity": "sha512-rH1U28wi+mHQlJFr4mvMW3D0oILnjV/BY9TslzWc6zM5zwv48I8LQk4sVzggN2KTDIGAdlDsmdReAd+7fhMYmQ==",
- "license": "MIT",
- "dependencies": {
- "core-js": "^3.6.5",
- "quill": "^1.3.7"
- },
- "peerDependencies": {
- "vue": "^3.0.0"
- }
- },
"node_modules/vuedraggable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
diff --git a/client/package.json b/client/package.json
index 9a7183cac..2060d0adb 100644
--- a/client/package.json
+++ b/client/package.json
@@ -60,12 +60,8 @@
"prismjs": "^1.29.0",
"qrcode": "^1.5.4",
"query-builder-vue-3": "^1.0.1",
-<<<<<<< HEAD
"quill": "^2.0.2",
- "tailwind-merge": "^2.3.0",
-=======
"tailwind-merge": "^2.5.4",
->>>>>>> main
"tinymotion": "^0.2.0",
"v-calendar": "^3.1.2",
"vue-chartjs": "^5.3.1",
From b1273dd403004a1db7f331753ef1afde861e4f76 Mon Sep 17 00:00:00 2001
From: Julien Nahum
Date: Tue, 22 Oct 2024 09:54:03 +0200
Subject: [PATCH 14/16] Polished email integration, added customer sender mail
---
.../Commands/EmailNotificationMigration.php | 29 ++--
.../Handlers/EmailIntegration.php | 1 +
.../Forms/FormEmailNotification.php | 141 ++++++++++++------
.../mail/form/email-notification.blade.php | 13 +-
.../Feature/Forms/EmailNotificationTest.php | 65 ++++++++
5 files changed, 181 insertions(+), 68 deletions(-)
diff --git a/api/app/Console/Commands/EmailNotificationMigration.php b/api/app/Console/Commands/EmailNotificationMigration.php
index 599f99926..b375bc4fd 100644
--- a/api/app/Console/Commands/EmailNotificationMigration.php
+++ b/api/app/Console/Commands/EmailNotificationMigration.php
@@ -29,19 +29,26 @@ class EmailNotificationMigration extends Command
*/
public function handle()
{
- FormIntegration::where(function ($query) {
- $query->where('integration_id', 'email')
- ->orWhere('integration_id', 'submission_confirmation');
- })->chunk(
- 100,
- function ($integrations) {
- foreach ($integrations as $integration) {
- $this->line('Process For Form: ' . $integration->form_id . ' - ' . $integration->integration_id . ' - ' . $integration->id);
+ if (app()->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']);
+ $totalCount = $query->count();
+ $progressBar = $this->output->createProgressBar($totalCount);
+ $progressBar->start();
- $this->updateIntegration($integration);
- }
+ $query->chunk(100, function ($integrations) use ($progressBar) {
+ foreach ($integrations as $integration) {
+ $this->updateIntegration($integration);
+ $progressBar->advance();
}
- );
+ });
+
+ $progressBar->finish();
+ $this->newLine();
$this->line('Migration Done');
}
diff --git a/api/app/Integrations/Handlers/EmailIntegration.php b/api/app/Integrations/Handlers/EmailIntegration.php
index 11ed071e7..d425c3456 100644
--- a/api/app/Integrations/Handlers/EmailIntegration.php
+++ b/api/app/Integrations/Handlers/EmailIntegration.php
@@ -17,6 +17,7 @@ public static function getValidationRules(): array
return [
'send_to' => 'required',
'sender_name' => 'required',
+ 'sender_email' => 'email|nullable',
'subject' => 'required',
'email_content' => 'required',
'include_submission_data' => 'boolean',
diff --git a/api/app/Notifications/Forms/FormEmailNotification.php b/api/app/Notifications/Forms/FormEmailNotification.php
index 20cf742c7..529aae8f5 100644
--- a/api/app/Notifications/Forms/FormEmailNotification.php
+++ b/api/app/Notifications/Forms/FormEmailNotification.php
@@ -11,6 +11,7 @@
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
use Vinkla\Hashids\Facades\Hashids;
+use Symfony\Component\Mime\Email;
class FormEmailNotification extends Notification implements ShouldQueue
{
@@ -18,7 +19,7 @@ class FormEmailNotification extends Notification implements ShouldQueue
public FormSubmitted $event;
public string $mailer;
- private $formattedData;
+ private array $formattedData;
/**
* Create a new notification instance.
@@ -29,15 +30,7 @@ public function __construct(FormSubmitted $event, private $integrationData, stri
{
$this->event = $event;
$this->mailer = $mailer;
-
- $formatter = (new FormSubmissionFormatter($event->form, $event->data))
- ->createLinks()
- ->outputStringsOnly()
- ->useSignedUrlForFiles();
- if ($this->integrationData->include_hidden_fields_submission_data ?? false) {
- $formatter->showHiddenFields();
- }
- $this->formattedData = $formatter->getFieldsWithValue();
+ $this->formattedData = $this->formatSubmissionData();
}
/**
@@ -62,36 +55,52 @@ public function toMail($notifiable)
return (new MailMessage())
->mailer($this->mailer)
->replyTo($this->getReplyToEmail($notifiable->routes['mail']))
- ->from($this->getFromEmail(), $this->integrationData->sender_name ?? config('app.name'))
+ ->from($this->getFromEmail(), $this->getSenderName())
->subject($this->getSubject())
- ->markdown('mail.form.email-notification', [
- 'emailContent' => $this->getEmailContent(),
- 'fields' => $this->formattedData,
- 'form' => $this->event->form,
- 'integrationData' => $this->integrationData,
- 'noBranding' => $this->event->form->no_branding,
- 'submission_id' => (isset($this->event->data['submission_id']) && $this->event->data['submission_id']) ? Hashids::encode($this->event->data['submission_id']) : null,
- ]);
+ ->withSymfonyMessage(function (Email $message) {
+ $this->addCustomHeaders($message);
+ })
+ ->markdown('mail.form.email-notification', $this->getMailData());
}
- private function getFromEmail()
+ private function formatSubmissionData(): array
{
- if (config('app.self_hosted')) {
- return config('mail.from.address');
+ $formatter = (new FormSubmissionFormatter($this->event->form, $this->event->data))
+ ->createLinks()
+ ->outputStringsOnly()
+ ->useSignedUrlForFiles();
+
+ if ($this->integrationData->include_hidden_fields_submission_data ?? false) {
+ $formatter->showHiddenFields();
}
- $originalFromAddress = Str::of(config('mail.from.address'))->explode('@');
+ 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 $originalFromAddress->first() . '+' . time() . '@' . $originalFromAddress->last();
+ return config('mail.from.address');
}
- private function getReplyToEmail($default)
+ 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) {
- $parser = new MentionParser($replyTo, $this->formattedData);
- $parsedReplyTo = $parser->parse();
+ $parsedReplyTo = $this->parseReplyTo($replyTo);
if ($parsedReplyTo && $this->validateEmail($parsedReplyTo)) {
return $parsedReplyTo;
}
@@ -100,40 +109,78 @@ private function getReplyToEmail($default)
return $this->getRespondentEmail() ?? $default;
}
- private function getSubject()
+ private function parseReplyTo(string $replyTo): ?string
{
- $parser = new MentionParser($this->integrationData->subject ?? 'New form submission', $this->formattedData);
+ $parser = new MentionParser($replyTo, $this->formattedData);
return $parser->parse();
}
- private function getRespondentEmail()
+ private function getSubject(): string
{
- // 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;
- }
+ $defaultSubject = 'New form submission';
+ $parser = new MentionParser($this->integrationData->subject ?? $defaultSubject, $this->formattedData);
+ return $parser->parse();
+ }
- if (isset($this->event->data[$emailFields->first()['id']])) {
- $email = $this->event->data[$emailFields->first()['id']];
- if ($this->validateEmail($email)) {
- return $email;
- }
- }
+ private function addCustomHeaders(Email $message): void
+ {
+ $formId = $this->event->form->id;
+ $submissionId = $this->event->data['submission_id'] ?? 'unknown';
+ $domain = Str::after(config('app.url'), '://');
- return null;
+ $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 getEmailContent()
+ 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/resources/views/mail/form/email-notification.blade.php b/api/resources/views/mail/form/email-notification.blade.php
index ad189ee23..bf268d0ef 100644
--- a/api/resources/views/mail/form/email-notification.blade.php
+++ b/api/resources/views/mail/form/email-notification.blade.php
@@ -9,19 +9,12 @@
@endif
@if($integrationData->include_submission_data)
-Here is the answer:
-
@foreach($fields as $field)
@if(isset($field['value']))
-
---------------------------------------------------------------------------------
-
-**{{$field['name']}}**
-
-
- {!! is_array($field['value'])?implode(',',$field['value']):$field['value']!!}
+
+{{$field['name']}}
+{!! is_array($field['value'])?implode(',',$field['value']):$field['value']!!}
-
@endif
@endforeach
@endif
diff --git a/api/tests/Feature/Forms/EmailNotificationTest.php b/api/tests/Feature/Forms/EmailNotificationTest.php
index b66e19ff2..03cf5acf4 100644
--- a/api/tests/Feature/Forms/EmailNotificationTest.php
+++ b/api/tests/Feature/Forms/EmailNotificationTest.php
@@ -98,3 +98,68 @@ function (FormEmailNotification $notification, $channels, $notifiable) {
}
);
});
+
+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');
+});
From 38d34765c0d42177ff8c77c52644df6714a6ec5f Mon Sep 17 00:00:00 2001
From: Julien Nahum
Date: Tue, 22 Oct 2024 10:18:34 +0200
Subject: [PATCH 15/16] Add missing changes
---
.../open/integrations/EmailIntegration.vue | 25 ++++++++++++++-----
client/lib/utils.js | 2 +-
client/nuxt.config.ts | 2 +-
3 files changed, 21 insertions(+), 8 deletions(-)
diff --git a/client/components/open/integrations/EmailIntegration.vue b/client/components/open/integrations/EmailIntegration.vue
index 70a619e3f..a03c5d882 100644
--- a/client/components/open/integrations/EmailIntegration.vue
+++ b/client/components/open/integrations/EmailIntegration.vue
@@ -22,12 +22,23 @@
required
label="Send To"
help="Add one email per line"
- />
-
+ />
+
+
+
+
useFeatureFlag('self_hosted'))
+
onBeforeMount(() => {
for (const [keyname, defaultValue] of Object.entries({
sender_name: "OpnForm",
diff --git a/client/lib/utils.js b/client/lib/utils.js
index b200c67fb..d1339a50e 100644
--- a/client/lib/utils.js
+++ b/client/lib/utils.js
@@ -88,7 +88,7 @@ export const getHost = function () {
* @returns {*}
*/
export const getDomain = function (url) {
- if (url.includes("localhost")) return "localhost"
+ if (!url || url.includes("localhost")) return "localhost"
try {
if (!url.startsWith("http")) url = "https://" + url
return new URL(url).hostname
diff --git a/client/nuxt.config.ts b/client/nuxt.config.ts
index b780de9f9..59dd594e2 100644
--- a/client/nuxt.config.ts
+++ b/client/nuxt.config.ts
@@ -12,7 +12,7 @@ export default defineNuxtConfig({
'@pinia/nuxt',
'@vueuse/nuxt',
'@vueuse/motion/nuxt',
- // '@nuxtjs/sitemap',
+ '@nuxtjs/sitemap',
'@nuxt/ui',
'nuxt-utm',
...process.env.NUXT_PUBLIC_GTM_CODE ? ['@zadigetvoltaire/nuxt-gtm'] : [],
From b155c98dc0c433c28d13894bb65a58a912d34271 Mon Sep 17 00:00:00 2001
From: Julien Nahum
Date: Tue, 22 Oct 2024 10:29:34 +0200
Subject: [PATCH 16/16] improve migration command
---
.../Commands/EmailNotificationMigration.php | 24 ++++++++++++-------
1 file changed, 16 insertions(+), 8 deletions(-)
diff --git a/api/app/Console/Commands/EmailNotificationMigration.php b/api/app/Console/Commands/EmailNotificationMigration.php
index b375bc4fd..15230ebd5 100644
--- a/api/app/Console/Commands/EmailNotificationMigration.php
+++ b/api/app/Console/Commands/EmailNotificationMigration.php
@@ -35,14 +35,20 @@ public function handle()
return 0;
}
}
- $query = FormIntegration::whereIn('integration_id', ['email', 'submission_confirmation']);
+ $query = FormIntegration::whereIn('integration_id', ['email', 'submission_confirmation'])
+ ->whereHas('form');
$totalCount = $query->count();
$progressBar = $this->output->createProgressBar($totalCount);
$progressBar->start();
- $query->chunk(100, function ($integrations) use ($progressBar) {
+ $query->with('form')->chunk(100, function ($integrations) use ($progressBar) {
foreach ($integrations as $integration) {
- $this->updateIntegration($integration);
+ try {
+ $this->updateIntegration($integration);
+ } catch (\Exception $e) {
+ $this->error('Error updating integration ' . $integration->id . '. Error: ' . $e->getMessage());
+ ray($e);
+ }
$progressBar->advance();
}
});
@@ -55,6 +61,9 @@ public function handle()
public function updateIntegration(FormIntegration $integration)
{
+ if (!$integration->form) {
+ return;
+ }
$existingData = $integration->data;
if ($integration->integration_id === 'email') {
$integration->data = [
@@ -69,7 +78,7 @@ public function updateIntegration(FormIntegration $integration)
} elseif ($integration->integration_id === 'submission_confirmation') {
$integration->integration_id = 'email';
$integration->data = [
- 'send_to' => $this->getMentionHtml($integration->form_id),
+ 'send_to' => $this->getMentionHtml($integration->form),
'sender_name' => $existingData->notification_sender,
'subject' => $existingData->notification_subject,
'email_content' => $existingData->notification_body,
@@ -81,15 +90,14 @@ public function updateIntegration(FormIntegration $integration)
return $integration->save();
}
- private function getMentionHtml($formId)
+ private function getMentionHtml(Form $form)
{
- $emailField = $this->getRespondentEmail($formId);
+ $emailField = $this->getRespondentEmail($form);
return $emailField ? '' . $emailField['name'] . '' : '';
}
- private function getRespondentEmail($formId)
+ private function getRespondentEmail(Form $form)
{
- $form = Form::find($formId);
$emailFields = collect($form->properties)->filter(function ($field) {
$hidden = $field['hidden'] ?? false;
return !$hidden && $field['type'] == 'email';