diff --git a/api/app/Http/Controllers/Forms/PublicFormController.php b/api/app/Http/Controllers/Forms/PublicFormController.php index f52bcd769..0110ab823 100644 --- a/api/app/Http/Controllers/Forms/PublicFormController.php +++ b/api/app/Http/Controllers/Forms/PublicFormController.php @@ -116,7 +116,7 @@ private function getRedirectData($form, $submissionData) return ['id' => $key, 'value' => $value]; })->values()->all(); - $redirectUrl = ($form->redirect_url) ? (new MentionParser($form->redirect_url, $formattedData))->parse() : null; + $redirectUrl = ($form->redirect_url) ? (new MentionParser($form->redirect_url, $formattedData))->parseAsText() : null; if ($redirectUrl && !filter_var($redirectUrl, FILTER_VALIDATE_URL)) { $redirectUrl = null; diff --git a/api/app/Integrations/Handlers/EmailIntegration.php b/api/app/Integrations/Handlers/EmailIntegration.php index 977e8e01f..b20a31e74 100644 --- a/api/app/Integrations/Handlers/EmailIntegration.php +++ b/api/app/Integrations/Handlers/EmailIntegration.php @@ -58,7 +58,7 @@ public function handle(): void 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(); + $sendTo = $parser->parseAsText(); } else { $sendTo = $this->integrationData?->send_to; } @@ -73,6 +73,7 @@ public function handle(): void 'form_slug' => $this->form->slug, 'mailer' => $this->mailer ]); + $recipients->each(function ($subscriber) { Notification::route('mail', $subscriber)->notify( new FormEmailNotification($this->event, $this->integrationData, $this->mailer) diff --git a/api/app/Notifications/Forms/FormEmailNotification.php b/api/app/Notifications/Forms/FormEmailNotification.php index 793f379ae..a616c93f3 100644 --- a/api/app/Notifications/Forms/FormEmailNotification.php +++ b/api/app/Notifications/Forms/FormEmailNotification.php @@ -110,14 +110,14 @@ private function getReplyToEmail($default): string private function parseReplyTo(string $replyTo): ?string { $parser = new MentionParser($replyTo, $this->formatSubmissionData(false)); - return $parser->parse(); + return $parser->parseAsText(); } private function getSubject(): string { $defaultSubject = 'New form submission'; $parser = new MentionParser($this->integrationData->subject ?? $defaultSubject, $this->formatSubmissionData(false)); - return $parser->parse(); + return $parser->parseAsText(); } private function addCustomHeaders(Email $message): void diff --git a/api/app/Open/MentionParser.php b/api/app/Open/MentionParser.php index 01c8e3c80..5cb3d1817 100644 --- a/api/app/Open/MentionParser.php +++ b/api/app/Open/MentionParser.php @@ -62,26 +62,64 @@ public function parse() return $result; } - private function replaceMentions() + public function parseAsText() { - $pattern = '/]*mention-field-id="([^"]*)"[^>]*mention-fallback="([^"]*)"[^>]*>.*?<\/span>/'; - return preg_replace_callback($pattern, function ($matches) { - $fieldId = $matches[1]; - $fallback = $matches[2]; - $value = $this->getData($fieldId); + // First use the existing parse method to handle mentions + $html = $this->parse(); - if ($value !== null) { - if (is_array($value)) { - return implode(' ', array_map(function ($v) { - return $v; - }, $value)); - } - return $value; - } elseif ($fallback) { - return $fallback; + $doc = new DOMDocument(); + $internalErrors = libxml_use_internal_errors(true); + + // Wrap in root element + $wrappedContent = '' . $html . ''; + + $doc->loadHTML(mb_convert_encoding($wrappedContent, 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + libxml_use_internal_errors($internalErrors); + + // Convert HTML to plain text with proper line breaks + $text = ''; + $this->domToText($doc->getElementsByTagName('root')->item(0), $text); + + // Clean up the text: + // 1. Remove escaped newlines + // 2. Replace multiple newlines with single newline + // 3. Trim whitespace + $text = str_replace(['\\n', '\n'], "\n", $text); + $text = preg_replace('/\n+/', "\n", trim($text)); + + // Ensure each line has exactly one email + $lines = explode("\n", $text); + $lines = array_map('trim', $lines); + $lines = array_filter($lines); // Remove empty lines + + return implode("\n", $lines); + } + + private function domToText($node, &$text) + { + if ($node->nodeType === XML_TEXT_NODE) { + $text .= $node->nodeValue; + return; + } + + $block_elements = ['div', 'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li']; + $nodeName = strtolower($node->nodeName); + + // Add newline before block elements + if (in_array($nodeName, $block_elements)) { + $text .= "\n"; + } + + if ($node->hasChildNodes()) { + foreach ($node->childNodes as $child) { + $this->domToText($child, $text); } - return ''; - }, $this->content); + } + + // Add newline after block elements + if (in_array($nodeName, $block_elements)) { + $text .= "\n"; + } } private function getData($fieldId) diff --git a/api/tests/Unit/Service/Forms/MentionParserTest.php b/api/tests/Unit/Service/Forms/MentionParserTest.php index 182839901..a193e0f3f 100644 --- a/api/tests/Unit/Service/Forms/MentionParserTest.php +++ b/api/tests/Unit/Service/Forms/MentionParserTest.php @@ -2,85 +2,175 @@ use App\Open\MentionParser; -test('it replaces mention elements with their corresponding values', function () { - $content = '

Hello Placeholder

'; - $data = [['id' => '123', 'value' => 'World']]; +describe('MentionParser', function () { + it('replaces mentions with their values in HTML', function () { + $content = '
Hello Name
'; + $data = [ + ['id' => '123', 'value' => 'John Doe'] + ]; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('
Hello John Doe
'); + }); + + it('uses fallback when value is not found', function () { + $content = 'Name'; + $data = []; + + $parser = new MentionParser($content, $data); + $result = $parser->parse(); + + expect($result)->toBe('Guest'); + }); - $parser = new MentionParser($content, $data); - $result = $parser->parse(); + it('removes the element when no value and no fallback is provided', function () { + $content = '
Hello Name!
'; + $data = []; - expect($result)->toBe('

Hello World

'); -}); + $parser = new MentionParser($content, $data); + $result = $parser->parse(); -test('it handles multiple mentions', function () { - $content = '

Name is Age years old

'; - $data = [ - ['id' => '123', 'value' => 'John'], - ['id' => '456', 'value' => 30], - ]; + expect($result)->toBe('
Hello !
'); + }); - $parser = new MentionParser($content, $data); - $result = $parser->parse(); + describe('parseAsText', function () { + it('converts HTML to plain text with proper line breaks', function () { + $content = '
First line
Second line
'; - expect($result)->toBe('

John is 30 years old

'); -}); + $parser = new MentionParser($content, []); + $result = $parser->parseAsText(); -test('it uses fallback when value is not found', function () { - $content = '

Hello Placeholder

'; - $data = []; + expect($result)->toBe("First line\nSecond line"); + }); - $parser = new MentionParser($content, $data); - $result = $parser->parse(); + it('handles email addresses with proper line breaks', function () { + $content = 'Email
john@example.com
'; + $data = [ + ['id' => '123', 'value' => 'jane@example.com'] + ]; - expect($result)->toBe('

Hello Friend

'); -}); + $parser = new MentionParser($content, $data); + $result = $parser->parseAsText(); -test('it removes mention element when no value and no fallback', function () { - $content = '

Hello Placeholder

'; - $data = []; + expect($result)->toBe("jane@example.com\njohn@example.com"); + }); - $parser = new MentionParser($content, $data); - $result = $parser->parse(); + it('handles multiple mentions and complex HTML structure', function () { + $content = ' +
Contact: Email1
+
CC: Email2
+
Additional: test@example.com
+ '; + $data = [ + ['id' => '123', 'value' => 'primary@example.com'], + ['id' => '456', 'value' => 'secondary@example.com'], + ]; - expect($result)->toBe('

Hello

'); -}); + $parser = new MentionParser($content, $data); + $result = $parser->parseAsText(); -test('it handles array values', function () { - $content = '

Tags: Placeholder

'; - $data = [['id' => '123', 'value' => ['PHP', 'Laravel', 'Testing']]]; + expect($result)->toBe( + "Contact: primary@example.com\n" . + "CC: secondary@example.com\n" . + "Additional: test@example.com" + ); + }); - $parser = new MentionParser($content, $data); - $result = $parser->parse(); + it('handles array values in mentions', function () { + $content = 'Emails'; + $data = [ + ['id' => '123', 'value' => ['first@test.com', 'second@test.com']] + ]; - expect($result)->toBe('

Tags: PHP, Laravel, Testing

'); -}); + $parser = new MentionParser($content, $data); + $result = $parser->parseAsText(); -test('it preserves HTML structure', function () { - $content = '

Hello Placeholder

How are you?

'; - $data = [['id' => '123', 'value' => 'World']]; + expect($result)->toBe('first@test.com, second@test.com'); + }); + }); - $parser = new MentionParser($content, $data); - $result = $parser->parse(); + test('it replaces mention elements with their corresponding values', function () { + $content = '

Hello Placeholder

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

Hello World

How are you?

'); -}); + $parser = new MentionParser($content, $data); + $result = $parser->parse(); -test('it handles UTF-8 characters', function () { - $content = '

こんにちは Placeholder

'; - $data = [['id' => '123', 'value' => '世界']]; + expect($result)->toBe('

Hello World

'); + }); - $parser = new MentionParser($content, $data); - $result = $parser->parse(); + test('it handles multiple mentions', function () { + $content = '

Name is Age years old

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

こんにちは 世界

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

John is 30 years old

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

Hello Placeholder

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

Hello Friend

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

Hello Placeholder

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

Hello

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

Tags: Placeholder

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

Tags: PHP, Laravel, Testing

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

Hello Placeholder

How are you?

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

Hello World

How are you?

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

こんにちは Placeholder

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

こんにちは 世界

'); + }); -test('it handles content without surrounding paragraph tags', function () { - $content = 'some text Post excerpt dewde'; - $data = [['id' => '123', 'value' => 'replaced text']]; + test('it handles content without surrounding paragraph tags', function () { + $content = 'some text Post excerpt dewde'; + $data = [['id' => '123', 'value' => 'replaced text']]; - $parser = new MentionParser($content, $data); - $result = $parser->parse(); + $parser = new MentionParser($content, $data); + $result = $parser->parse(); - expect($result)->toBe('some text replaced text dewde'); + expect($result)->toBe('some text replaced text dewde'); + }); });