[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