From 26f67b2428616fe149fa468e762f487f67146b08 Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Wed, 6 May 2026 09:27:53 -0400 Subject: [PATCH 1/2] FOUR-30587 Actions By Email - Handle Replies process creating requests even the ABE is not used Description: Skip ABE Handle Replies timer when inbound mail config is inadequate - Gate timer start in `TaskSchedulerManager::executeTimerStartEvent()` for the Handle Replies process before `WorkflowManager::triggerStartEvent()`. - Treat config as adequate only when host, username, and credentials exist (password or OAuth token keys under `abe_imap_*`). - Log and rename test to `testScheduleMustNotStartHandleRepliesTimerWhenAbeInboundConfigInadequate`. Related tickets: https://processmaker.atlassian.net/browse/FOUR-30587 --- .../Managers/TaskSchedulerManager.php | 155 ++++++++++++++++++ tests/Feature/Api/TimerStartEventTest.php | 21 +++ 2 files changed, 176 insertions(+) diff --git a/ProcessMaker/Managers/TaskSchedulerManager.php b/ProcessMaker/Managers/TaskSchedulerManager.php index cd93db4e6d..ad81137748 100644 --- a/ProcessMaker/Managers/TaskSchedulerManager.php +++ b/ProcessMaker/Managers/TaskSchedulerManager.php @@ -21,6 +21,7 @@ use ProcessMaker\Models\ProcessRequestLock; use ProcessMaker\Models\ScheduledTask; use ProcessMaker\Models\TimerExpression; +use ProcessMaker\Models\Setting; use ProcessMaker\Nayra\Bpmn\Models\BoundaryEvent; use ProcessMaker\Nayra\Bpmn\Models\DatePeriod; use ProcessMaker\Nayra\Bpmn\Models\IntermediateCatchEvent; @@ -398,6 +399,15 @@ public function executeTimerStartEvent(ScheduledTask $task, $config) if (!$definitions->findElementById($config->element_id)) { return; } + + if ($this->shouldSkipHandleRepliesTimerStart($process)) { + Log::info('Skipping Actions By Email Handle Replies timer event because ABE inbound mail configuration is not adequate', [ + 'process_id' => $process->id, + 'process_name' => $process->name, + ]); + return; + } + $event = $definitions->getEvent($config->element_id); $data = []; @@ -405,6 +415,151 @@ public function executeTimerStartEvent(ScheduledTask $task, $config) $processRequest = WorkflowManager::triggerStartEvent($process, $event, $data); } + /** + * Determine if the timer should be skipped for the Actions By Email handle replies process. + * + * @param Process $process + * @return bool + */ + private function shouldSkipHandleRepliesTimerStart(Process $process): bool + { + if (!$this->isHandleRepliesProcess($process)) { + return false; + } + + return !$this->hasAdequateAbeInboundConfiguration(); + } + + /** + * Whether Actions By Email has enough configuration to poll inbound mail (IMAP or OAuth). + * + * This is a heuristic in core: the connector may add more keys; we avoid starting the + * Handle Replies timer when the mailbox clearly is not set up (FOUR-30587). + * + * @return bool + */ + private function hasAdequateAbeInboundConfiguration(): bool + { + if (!Setting::readyToUseSettingsDatabase()) { + return false; + } + + $host = $this->getAbeInboundSettingValue(['abe_imap_host', 'email_connector_mail_host']); + $username = $this->getAbeInboundSettingValue(['abe_imap_username', 'email_connector_mail_username']); + + if (!$this->hasValue($host) || !$this->hasValue($username)) { + return false; + } + + return $this->hasAnyAbeInboundCredential(); + } + + /** + * Standard password or OAuth-style tokens stored under abe_imap_* settings. + */ + private function hasAnyAbeInboundCredential(): bool + { + if ($this->hasValue($this->getAbeInboundSettingValue(['abe_imap_password', 'email_connector_mail_password']))) { + return true; + } + + return Setting::query() + ->whereRaw('LOWER(`key`) LIKE ?', ['abe_imap%']) + ->where(function ($query) { + $query->whereRaw('LOWER(`key`) LIKE ?', ['%access_token%']) + ->orWhereRaw('LOWER(`key`) LIKE ?', ['%refresh_token%']); + }) + ->get() + ->contains(function ($setting) { + return $this->hasValue($this->extractSettingValue($setting->config)); + }); + } + + /** + * Reads the first non-empty value from supported Actions By Email / mail settings keys. + * + * @param array $prefixes + * @return string|null + */ + private function getAbeInboundSettingValue(array $prefixes): ?string + { + $settings = Setting::query()->where(function ($query) use ($prefixes) { + foreach ($prefixes as $prefix) { + $query->orWhereRaw('LOWER(`key`) LIKE ?', [strtolower($prefix) . '%']); + } + })->get(); + + if ($settings->isEmpty()) { + return null; + } + + foreach ($settings as $setting) { + $value = $this->extractSettingValue($setting->config); + if ($this->hasValue($value)) { + return (string) $value; + } + } + + return null; + } + + /** + * Normalize setting values from different possible setting formats. + * + * @param mixed $value + * @return mixed + */ + private function extractSettingValue($value) + { + if (is_object($value)) { + $value = (array) $value; + } + + if (is_array($value)) { + if (array_key_exists('value', $value)) { + return $value['value']; + } + + return $value; + } + + return $value; + } + + /** + * Check if a setting value is present and not empty. + * + * @param mixed $value + * @return bool + */ + private function hasValue($value): bool + { + if (is_array($value)) { + foreach ($value as $item) { + if ($this->hasValue($item)) { + return true; + } + } + + return false; + } + + return is_scalar($value) && trim((string) $value) !== ''; + } + + /** + * Identify the handle replies process by name. + * + * @param Process $process + * @return bool + */ + private function isHandleRepliesProcess(Process $process): bool + { + $name = (string) $process->name; + + return stripos($name, 'actions by email') !== false && stripos($name, 'handle replies') !== false; + } + /** * Execute a timer start event * diff --git a/tests/Feature/Api/TimerStartEventTest.php b/tests/Feature/Api/TimerStartEventTest.php index 88b483e7be..27635609ab 100644 --- a/tests/Feature/Api/TimerStartEventTest.php +++ b/tests/Feature/Api/TimerStartEventTest.php @@ -228,4 +228,25 @@ public function testScheduleMustNotStartTimerEventWhenProcessInactive() $task->type = 'TIMER_START_EVENT'; $manager->executeTimerStartEvent($task, json_decode($task->configuration)); } + + public function testScheduleMustNotStartHandleRepliesTimerWhenAbeInboundConfigInadequate() + { + // triggerStartEvent must not run when ABE inbound mail settings are missing/inadequate + WorkflowManager::shouldReceive('triggerStartEvent') + ->never() + ->with(\Mockery::any(), \Mockery::any(), \Mockery::any()); + + $data = []; + $data['name'] = 'Actions By Email - Handle Replies'; + $data['bpmn'] = Process::getProcessTemplate('TimerStartEvent.bpmn'); + + $process = Process::factory()->create($data); + + $manager = new TaskSchedulerManager(); + $task = new ScheduledTask(); + $task->process_id = $process->id; + $task->configuration = '{"type":"TimeCycle","interval":"R4\/2019-02-13T13:08:00Z\/PT1M", "element_id" : "_9"}'; + $task->type = 'TIMER_START_EVENT'; + $manager->executeTimerStartEvent($task, json_decode($task->configuration)); + } } From 16611210cc068e9395f201f9f0eaf5fa8d723bf2 Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Tue, 19 May 2026 09:41:56 -0400 Subject: [PATCH 2/2] fix(abe): stop Handle Replies watchdog from running without valid inbound mail Problem: - The scheduled command `package-actions-by-email:check-handler-process-stopped` runs every minute and was restarting the "Actions By Email - Handle Replies" process even when ABE was not in use or IMAP/OAuth was missing or invalid. - That caused extra system requests, scheduler noise, and repeated IMAP errors (FOUR-28485, FOUR-30587). Solution: - Skip the watchdog when there are no active ABE subprocess tasks. - Skip restart when inbound mail configuration is incomplete (standard IMAP, Gmail OAuth, or Office 365 OAuth). - Harden core timer-start guard so Handle Replies timer events are not started without adequate ABE inbound settings, using `package_key` and correct setting/env variable checks. Modified files: - package-actions-by-email/src/Console/Commands/CheckHandlerProcessStopped.php - package-actions-by-email/src/ProcessProvider.php - processmaker4/ProcessMaker/Managers/TaskSchedulerManager.php Tests: - package-actions-by-email/tests/Feature/CheckHandlerProcessStoppedCommandTest.php (new) - package-actions-by-email/tests/ProcessProviderTest.php - processmaker4/tests/unit/ProcessMaker/Managers/TaskSchedulerManagerTest.php --- .../Managers/TaskSchedulerManager.php | 95 ++++++++++++++----- .../Managers/TaskSchedulerManagerTest.php | 64 +++++++++++++ 2 files changed, 134 insertions(+), 25 deletions(-) diff --git a/ProcessMaker/Managers/TaskSchedulerManager.php b/ProcessMaker/Managers/TaskSchedulerManager.php index ad81137748..10e26e4fd4 100644 --- a/ProcessMaker/Managers/TaskSchedulerManager.php +++ b/ProcessMaker/Managers/TaskSchedulerManager.php @@ -16,12 +16,13 @@ use PDOException; use ProcessMaker\Facades\WorkflowManager; use ProcessMaker\Jobs\StartEventConditional; +use ProcessMaker\Models\EnvironmentVariable; use ProcessMaker\Models\Process; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestLock; use ProcessMaker\Models\ScheduledTask; -use ProcessMaker\Models\TimerExpression; use ProcessMaker\Models\Setting; +use ProcessMaker\Models\TimerExpression; use ProcessMaker\Nayra\Bpmn\Models\BoundaryEvent; use ProcessMaker\Nayra\Bpmn\Models\DatePeriod; use ProcessMaker\Nayra\Bpmn\Models\IntermediateCatchEvent; @@ -405,6 +406,7 @@ public function executeTimerStartEvent(ScheduledTask $task, $config) 'process_id' => $process->id, 'process_name' => $process->name, ]); + return; } @@ -444,56 +446,80 @@ private function hasAdequateAbeInboundConfiguration(): bool return false; } - $host = $this->getAbeInboundSettingValue(['abe_imap_host', 'email_connector_mail_host']); + $authMethodIndex = (int) ($this->getAbeInboundSettingValue(['abe_imap_auth_method']) ?? 0); $username = $this->getAbeInboundSettingValue(['abe_imap_username', 'email_connector_mail_username']); - if (!$this->hasValue($host) || !$this->hasValue($username)) { + if (!$this->hasValue($username)) { return false; } - return $this->hasAnyAbeInboundCredential(); + if ($authMethodIndex === 1) { + return $this->hasEnvironmentVariables([ + 'ABE_GMAIL_API_CLIENT_ID', + 'ABE_GMAIL_API_SECRET', + 'ABE_GMAIL_API_ACCESS_TOKEN', + 'ABE_GMAIL_API_REFRESH_TOKEN', + ]); + } + + if ($authMethodIndex === 2) { + return $this->hasEnvironmentVariables([ + 'ABE_OFFICE_365_CLIENT_ID', + 'ABE_OFFICE_365_TENANT_ID', + 'ABE_OFFICE_365_SECRET', + 'ABE_OFFICE_365_ACCESS_TOKEN', + 'ABE_OFFICE_365_REFRESH_TOKEN', + 'ABE_OFFICE_365_ACCESS_TOKEN_EXPIRE_DATE', + ]); + } + + return $this->hasStandardAbeInboundConfiguration(); } /** - * Standard password or OAuth-style tokens stored under abe_imap_* settings. + * IMAP configuration for standard authentication mode. */ - private function hasAnyAbeInboundCredential(): bool + private function hasStandardAbeInboundConfiguration(): bool { - if ($this->hasValue($this->getAbeInboundSettingValue(['abe_imap_password', 'email_connector_mail_password']))) { + $password = $this->getAbeInboundSettingValue(['abe_imap_password', 'email_connector_mail_password']); + if (!$this->hasValue($password)) { + return false; + } + + $inboxUri = $this->getAbeInboundSettingValue(['abe_imap_inbox_uri']); + if ($this->hasValue($inboxUri)) { return true; } - return Setting::query() - ->whereRaw('LOWER(`key`) LIKE ?', ['abe_imap%']) - ->where(function ($query) { - $query->whereRaw('LOWER(`key`) LIKE ?', ['%access_token%']) - ->orWhereRaw('LOWER(`key`) LIKE ?', ['%refresh_token%']); - }) - ->get() - ->contains(function ($setting) { - return $this->hasValue($this->extractSettingValue($setting->config)); - }); + $server = $this->getAbeInboundSettingValue(['abe_imap_server', 'email_connector_mail_host']); + $port = $this->getAbeInboundSettingValue(['abe_imap_port', 'email_connector_mail_port']); + + return $this->hasValue($server) && $this->hasValue($port); } /** * Reads the first non-empty value from supported Actions By Email / mail settings keys. * - * @param array $prefixes + * @param array $keys * @return string|null */ - private function getAbeInboundSettingValue(array $prefixes): ?string + private function getAbeInboundSettingValue(array $keys): ?string { - $settings = Setting::query()->where(function ($query) use ($prefixes) { - foreach ($prefixes as $prefix) { - $query->orWhereRaw('LOWER(`key`) LIKE ?', [strtolower($prefix) . '%']); - } - })->get(); + $settings = Setting::query() + ->whereIn('key', $keys) + ->get() + ->keyBy('key'); if ($settings->isEmpty()) { return null; } - foreach ($settings as $setting) { + foreach ($keys as $key) { + $setting = $settings->get($key); + if (!$setting) { + continue; + } + $value = $this->extractSettingValue($setting->config); if ($this->hasValue($value)) { return (string) $value; @@ -555,11 +581,30 @@ private function hasValue($value): bool */ private function isHandleRepliesProcess(Process $process): bool { + if ((string) $process->package_key === 'package-actions-by-email/handle-replies') { + return true; + } + $name = (string) $process->name; return stripos($name, 'actions by email') !== false && stripos($name, 'handle replies') !== false; } + private function hasEnvironmentVariables(array $names): bool + { + $values = EnvironmentVariable::query() + ->whereIn('name', $names) + ->pluck('value', 'name'); + + foreach ($names as $name) { + if (!isset($values[$name]) || trim((string) $values[$name]) === '') { + return false; + } + } + + return true; + } + /** * Execute a timer start event * diff --git a/tests/unit/ProcessMaker/Managers/TaskSchedulerManagerTest.php b/tests/unit/ProcessMaker/Managers/TaskSchedulerManagerTest.php index 7937ed4211..3e3d1c5adc 100644 --- a/tests/unit/ProcessMaker/Managers/TaskSchedulerManagerTest.php +++ b/tests/unit/ProcessMaker/Managers/TaskSchedulerManagerTest.php @@ -4,6 +4,10 @@ use Carbon\Carbon; use DateTime; +use ProcessMaker\Models\EnvironmentVariable; +use ProcessMaker\Models\Process; +use ProcessMaker\Models\Setting; +use ReflectionMethod; use Tests\TestCase; class TaskSchedulerManagerTest extends TestCase @@ -74,4 +78,64 @@ public function testTruncateDates() $rounded = $this->manager->truncateDateTime($date); $this->assertEquals('00:01:00', $rounded->format('H:i:s')); } + + public function testHasAdequateAbeInboundConfigurationForStandardAuth() + { + Setting::updateOrCreate(['key' => 'abe_imap_auth_method'], ['config' => '0']); + Setting::updateOrCreate(['key' => 'abe_imap_username'], ['config' => 'abe@test.com']); + Setting::updateOrCreate(['key' => 'abe_imap_password'], ['config' => '123Test']); + Setting::updateOrCreate(['key' => 'abe_imap_server'], ['config' => 'imap.example.com']); + Setting::updateOrCreate(['key' => 'abe_imap_port'], ['config' => '993']); + + $this->assertTrue((bool) $this->invokePrivateMethod('hasAdequateAbeInboundConfiguration')); + } + + public function testHasAdequateAbeInboundConfigurationForGoogleOauth() + { + Setting::updateOrCreate(['key' => 'abe_imap_auth_method'], ['config' => '1']); + Setting::updateOrCreate(['key' => 'abe_imap_username'], ['config' => 'abe@test.com']); + + foreach ([ + 'ABE_GMAIL_API_CLIENT_ID', + 'ABE_GMAIL_API_SECRET', + 'ABE_GMAIL_API_ACCESS_TOKEN', + 'ABE_GMAIL_API_REFRESH_TOKEN', + ] as $name) { + EnvironmentVariable::factory()->create([ + 'name' => $name, + 'value' => 'value-' . strtolower($name), + ]); + } + + $this->assertTrue((bool) $this->invokePrivateMethod('hasAdequateAbeInboundConfiguration')); + } + + public function testHasAdequateAbeInboundConfigurationReturnsFalseWhenUsernameIsMissing() + { + Setting::updateOrCreate(['key' => 'abe_imap_auth_method'], ['config' => '0']); + Setting::updateOrCreate(['key' => 'abe_imap_username'], ['config' => '']); + Setting::updateOrCreate(['key' => 'abe_imap_password'], ['config' => '123Test']); + Setting::updateOrCreate(['key' => 'abe_imap_server'], ['config' => 'imap.example.com']); + Setting::updateOrCreate(['key' => 'abe_imap_port'], ['config' => '993']); + + $this->assertFalse((bool) $this->invokePrivateMethod('hasAdequateAbeInboundConfiguration')); + } + + public function testIsHandleRepliesProcessUsesPackageKey() + { + $process = new Process([ + 'package_key' => 'package-actions-by-email/handle-replies', + 'name' => 'Anything', + ]); + + $this->assertTrue((bool) $this->invokePrivateMethod('isHandleRepliesProcess', [$process])); + } + + private function invokePrivateMethod(string $method, array $args = []) + { + $reflection = new ReflectionMethod($this->manager, $method); + $reflection->setAccessible(true); + + return $reflection->invokeArgs($this->manager, $args); + } }