[TikiWiki-commits] [Git][tikiwiki/tiki][master] [FIX] EmailAction: use RFC-compliant address parsing via Symfony Mime

"Victor Emanouilov \(@kroky\) via TikiWiki-cvs" <[email protected]>
Newsgroups gmane.comp.cms.tiki.cvs
Message-ID <69b95abc49d52_3b509d6d45426c@gitlab-sidekiq-low-urgency-cpu-bound-v2-7fb98cc9d4-kgbfx.mail>

Victor Emanouilov pushed to branch master at Tiki Wiki CMS Groupware / Tiki


Commits:
b109cd37 by Olivier Kango at 2026-03-17T13:35:37+00:00
[FIX] EmailAction: use RFC-compliant address parsing via Symfony Mime
---
* Fix EmailAction test PHPCS violations

* Refactor EmailAction tests to cover execute() instead of private helpers

* Search: support Outlook semicolon-separated email lists

* [FIX] PHPCS: disable missing namespace sniff for legacy EmailAction test class

* [FIX] PHPCS: make EmailAction test class PascalCase and mark legacy global namespace

* [FIX] EmailAction: align RFC/user fallback and expand dereference unit tests

* [FIX] EmailAction: keep RFC-first parsing, username fallback, and add unit coverage

* [FIX] Search EmailAction: fallback to username parsing when RFC address parsing yields empty list

* [FIX] Search EmailAction: rely on Address:createArray public API and keep username fallback

* [FIX] EmailAction: use RFC-compliant address parsing via Symfony Mime

[FIX] EmailAction: switch to Symfony Address:createArray for RFC TO/CC parsing and remove custom split logic

Remove the fix for LISTEXECUTE 404 on master when page context is missing

Update listexecute.js with master

See merge request tikiwiki/tiki!9277

- - - - -


2 changed files:

- lib/core/Search/Action/EmailAction.php
- + lib/test/Core/Search/EmailActionTest.php


Changes:

=====================================
lib/core/Search/Action/EmailAction.php
=====================================
@@ -4,6 +4,9 @@
 //
 // All Rights Reserved. See copyright.txt for details and a complete list of authors.
 // Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details.
+
+use Symfony\Component\Mime\Address;
+
 class Search_Action_EmailAction implements Search_Action_Action
 {
     public function getValues()
@@ -34,7 +37,7 @@ class Search_Action_EmailAction implements Search_Action_Action
     public function execute(JitFilter $data)
     {
         try {
-            $mail = new TikiMail();
+            $mail = $this->createMail();
 
             if ($replyto = $this->dereference($data->replyto->raw())) {
                 $mail->setReplyTo($replyto[0]);
@@ -179,6 +182,11 @@ class Search_Action_EmailAction implements Search_Action_Action
         return false;
     }
 
+    protected function createMail(): TikiMail
+    {
+        return new TikiMail();
+    }
+
     private function parse($content, $is_html = null)
     {
         $content = "~np~$content~/np~";
@@ -208,42 +216,83 @@ class Search_Action_EmailAction implements Search_Action_Action
         if (empty($email_or_username)) {
             return [];
         }
-        if (str_contains($email_or_username, ';')) {
-            $list = preg_split('/\s*;\s*/', $email_or_username);
-            $res = [];
-            foreach ($list as $email_or_username) {
-                $res = array_merge($res, $this->dereference($email_or_username));
+        $email_or_username = trim($this->stripNp($email_or_username));
+
+        try {
+            $resolvedAddresses = [];
+            foreach ($this->parseAddressList($email_or_username) as $address) {
+                $name = $address->getName();
+                if ($name !== '') {
+                    $resolvedAddresses[$name] = $address->getAddress();
+                } else {
+                    $resolvedAddresses[] = $address->getAddress();
+                }
             }
-            return array_filter($res);
-        }
-        if (str_contains($email_or_username, ',') && ! str_contains($email_or_username, '<')) {
-            $list = preg_split('/\s*,\s*/', $email_or_username);
-            $res = [];
-            foreach ($list as $email_or_username) {
-                $res = array_merge($res, $this->dereference($email_or_username));
+            $resolvedAddresses = array_filter($resolvedAddresses);
+            if (! empty($resolvedAddresses)) {
+                return $resolvedAddresses;
             }
-            return array_filter($res);
+        } catch (\Throwable $e) {
+            // Keep fallback below only for usernames.
         }
-        $email_or_username = trim($this->stripNp($email_or_username));
-        if (preg_match_all('/([^<]*?)<([^@>]+@[^>]+)>/', $email_or_username, $m)) {
-            $emails = [];
-            foreach ($m[0] as $key => $_) {
-                $name = trim($m[1][$key], ",;\n\r\t ");
-                $emails[$name] = $m[2][$key];
+
+        if (str_contains($email_or_username, '@')) {
+            return [];
+        }
+
+        $users = TikiLib::lib('trk')->parse_user_field($email_or_username);
+        return array_filter(array_map(function ($username) {
+            return TikiLib::lib('user')->get_user_email($username);
+        }, $users));
+    }
+
+    /**
+     * Parse an RFC-compliant mailbox list into individual Address objects.
+     *
+     * Supports comma-separated, semicolon-separated, and line-separated entries
+     * while honoring quoted names that may contain delimiters.
+     */
+    private function parseAddressList(string $addresses): array
+    {
+        $parsed = [];
+        $buffer = '';
+        $inQuotes = false;
+        $escape = TikiLib::TIKI_GLOBAL_CSV_ESCAPE_CHAR;
+
+        foreach (str_split($addresses) as $character) {
+            if ($character === '"' && ! $this->isEscaped($buffer, $escape)) {
+                $inQuotes = ! $inQuotes;
             }
-            return $emails;
-        } elseif (preg_match_all('/[^@]+@[^,;]+/', $email_or_username, $m)) {
-            return array_map(function ($email) {
-                return trim($email, ",;\n\r\t ");
-            }, $m[0]);
-        } elseif (str_contains($email_or_username, '@')) {
-            return [$email_or_username];
-        } else {
-            $users = TikiLib::lib('trk')->parse_user_field($email_or_username);
-            return array_filter(array_map(function ($username) {
-                return TikiLib::lib('user')->get_user_email($username);
-            }, $users));
+
+            if (! $inQuotes && ($character === ',' || $character === ';' || $character === "\n" || $character === "\r")) {
+                $candidate = trim($buffer);
+                if ($candidate !== '') {
+                    $parsed[] = $candidate;
+                }
+                $buffer = '';
+                continue;
+            }
+
+            $buffer .= $character;
         }
+
+        $candidate = trim($buffer);
+        if ($candidate !== '') {
+            $parsed[] = $candidate;
+        }
+
+        return Address::createArray($parsed);
+    }
+
+    private function isEscaped(string $buffer, string $escape): bool
+    {
+        $escapeCount = 0;
+
+        for ($index = strlen($buffer) - 1; $index >= 0 && $buffer[$index] === $escape; $index--) {
+            $escapeCount++;
+        }
+
+        return $escapeCount % 2 === 1;
     }
 
     private function dereferenceName($email_or_username)


=====================================
lib/test/Core/Search/EmailActionTest.php
=====================================
@@ -0,0 +1,187 @@
+<?php
+
+// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project
+//
+// All Rights Reserved. See copyright.txt for details and a complete list of authors.
+// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details.
+
+// phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
+class SearchActionEmailActionTest extends TikiTestCase
+{
+    /**
+     * @var Search_Action_EmailAction
+     */
+    private $action;
+
+    /**
+     * @var PHPUnit\Framework\MockObject\MockObject|TikiMail
+     */
+    private $mail;
+
+    /**
+     * @var TestableTikiLib|null
+     */
+    private $overrideLibs;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->mail = $this->getMockBuilder(TikiMail::class)
+            ->disableOriginalConstructor()
+            ->onlyMethods(['setReplyTo', 'setFrom', 'setSender', 'setSubject', 'setHtml', 'addAttachment', 'send'])
+            ->getMock();
+
+        $mail = $this->mail;
+        $this->action = new class ($mail) extends Search_Action_EmailAction {
+            /**
+             * @var TikiMail
+             */
+            private $mail;
+
+            public function __construct(TikiMail $mail)
+            {
+                $this->mail = $mail;
+            }
+
+            protected function createMail(): TikiMail
+            {
+                return $this->mail;
+            }
+        };
+        $this->overrideLibs = new TestableTikiLib();
+
+        $parserLib = $this->createMock(get_class(TikiLib::lib('parser')));
+        $parserLib->method('parse_data')
+            ->willReturnCallback(function ($content, $options = null) {
+                return str_replace(['~np~', '~/np~'], '', $content);
+            });
+
+        $this->overrideLibs->overrideLibs(['parser' => $parserLib]);
+    }
+
+    protected function tearDown(): void
+    {
+        $this->overrideLibs = null;
+        parent::tearDown();
+    }
+
+    public function testExecuteParsesSimpleRfcAddressList()
+    {
+        $this->expectSend(['[email protected]', '[email protected]'], true);
+
+        $this->assertTrue($this->action->execute($this->createInput([
+            'to' => ['[email protected], [email protected]'],
+        ])));
+    }
+
+    public function testExecuteParsesNamedRfcAddressListWithQuotedCommas()
+    {
+        $this->expectSend(['[email protected]', '[email protected]'], true);
+
+        $this->assertTrue($this->action->execute($this->createInput([
+            'to' => ['"Doe, John" <[email protected]>, Jane Doe <[email protected]>'],
+        ])));
+    }
+
+    public function testExecuteParsesSemicolonSeparatedNamedRfcAddressListWithQuotedCommas()
+    {
+        $this->expectSend(['[email protected]', '[email protected]'], true);
+
+        $this->assertTrue($this->action->execute($this->createInput([
+            'to' => ['"Doe, John" <[email protected]>; "Smith, Jane" <[email protected]>'],
+        ])));
+    }
+
+    public function testExecuteParsesRfcAddressListAcrossMultipleLines()
+    {
+        $this->expectSend(['[email protected]', '[email protected]'], true);
+
+        $this->assertTrue($this->action->execute($this->createInput([
+            'to' => ["Alice <[email protected]>\nBob <[email protected]>"],
+        ])));
+    }
+
+    public function testExecuteParsesMixedNamedAndPlainRfcAddresses()
+    {
+        $this->expectSend(['[email protected]', '[email protected]'], true);
+
+        $this->assertTrue($this->action->execute($this->createInput([
+            'to' => ['Alice <[email protected]>, [email protected]'],
+        ])));
+    }
+
+    public function testExecuteFallsBackToCommaSeparatedTikiUsernames()
+    {
+        $trkLib = $this->createMock(get_class(TikiLib::lib('trk')));
+        $trkLib->expects($this->once())
+            ->method('parse_user_field')
+            ->with('alice,bob')
+            ->willReturn(['alice', 'bob']);
+
+        $userLib = $this->createMock(get_class(TikiLib::lib('user')));
+        $userLib->expects($this->exactly(2))
+            ->method('get_user_email')
+            ->willReturnMap([
+                ['alice', '[email protected]'],
+                ['bob', '[email protected]'],
+            ]);
+
+        $this->overrideLibs->overrideLibs([
+            'trk' => $trkLib,
+            'user' => $userLib,
+        ]);
+
+        $this->expectSend(['[email protected]', '[email protected]'], true);
+
+        $this->assertTrue($this->action->execute($this->createInput([
+            'to' => ['alice,bob'],
+        ])));
+    }
+
+    public function testExecuteReturnsFalseForInvalidInputContainingAtSign()
+    {
+        $this->expectSend([], false);
+
+        $this->assertFalse($this->action->execute($this->createInput([
+            'to' => ['not-an-email@'],
+        ])));
+    }
+
+    public function testExecuteStripsNoParseMarkersBeforeParsing()
+    {
+        $this->expectSend(['[email protected]'], true);
+
+        $this->assertTrue($this->action->execute($this->createInput([
+            'to' => ['~np~"Alice, Test" <[email protected]>~/np~'],
+        ])));
+    }
+
+    private function createInput(array $overrides = []): JitFilter
+    {
+        return new JitFilter(array_merge([
+            'object_type' => '',
+            'object_id' => 0,
+            'replyto' => null,
+            'to' => [],
+            'cc' => [],
+            'bcc' => [],
+            'from' => null,
+            'subject' => 'Subject',
+            'content' => 'Body',
+            'is_html' => 0,
+            'pdf_page_attachment' => '',
+            'file_attachments' => [],
+            'file_attachment_field' => '',
+            'file_attachment_gal' => '',
+        ], $overrides));
+    }
+
+    private function expectSend(array $recipients, bool $result): void
+    {
+        $this->mail->expects($this->once())
+            ->method('send')
+            ->with($recipients)
+            ->willReturn($result);
+    }
+}



View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/b109cd375a6ca111ec00b2c72af2649fdcec9ecf

-- 
View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/b109cd375a6ca111ec00b2c72af2649fdcec9ecf
You're receiving this email because of your account on gitlab.com. Manage all notifications: https://gitlab.com/-/profile/notifications | Help: https://gitlab.com/help

_______________________________________________
TikiWiki-cvs mailing list
[email protected]
https://lists.sourceforge.net/lists/listinfo/tikiwiki-cvs
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.