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 @@ + + + \ 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 @@ - - - + :style="{ + '--font-size': theme.default.fontSize + }" + > + + @@ -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 @@ - - - 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 @@ -
- @@ -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';