diff --git a/app/Console/Commands/Support/GmailResendCompletionCommand.php b/app/Console/Commands/Support/GmailResendCompletionCommand.php new file mode 100644 index 000000000..dd9519be9 --- /dev/null +++ b/app/Console/Commands/Support/GmailResendCompletionCommand.php @@ -0,0 +1,47 @@ +argument('case_id')); + $approval = SupportApproval::query() + ->where('support_case_id', $case->id) + ->where('status', 'approved') + ->latest('id') + ->first(); + + if (!$approval) { + $this->error('No approved approval record for case #'.$case->id); + + return self::FAILURE; + } + + $executed = $case->actions()->where('action_name', 'approved_action_executed')->latest()->first(); + $output = is_array($executed?->output_json) ? $executed->output_json : []; + $succeeded = (bool) ($output['ok'] ?? false) || $case->status === 'verified'; + + $payload = $approvalEmail->sendActionCompletion( + $case, + $approval, + (string) $approval->requested_action, + $output, + $succeeded, + ); + + $this->line(json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return ($payload['ok'] ?? false) ? self::SUCCESS : self::FAILURE; + } +} diff --git a/app/Jobs/Support/ExecuteApprovedSupportActionJob.php b/app/Jobs/Support/ExecuteApprovedSupportActionJob.php index a92db8820..bd899eddd 100644 --- a/app/Jobs/Support/ExecuteApprovedSupportActionJob.php +++ b/app/Jobs/Support/ExecuteApprovedSupportActionJob.php @@ -64,7 +64,7 @@ public function handle( approvedBy: $approval->approved_by, correlationId: $case->correlation_id, ); - $approvalEmail->sendActionCompletion($case, $approval, $action, $noneResult, true); + $this->sendCompletionEmail($approvalEmail, $logger, $case, $approval, $action, $noneResult, true); return; } @@ -114,7 +114,47 @@ public function handle( errorMessage: $ok ? null : implode(';', (array) ($result['errors'] ?? [])), ); - $approvalEmail->sendActionCompletion($case, $approval, $action, $result ?? [], $ok); + $this->sendCompletionEmail($approvalEmail, $logger, $case, $approval, $action, $result ?? [], $ok); + } + + /** + * @param array $result + */ + private function sendCompletionEmail( + SupportApprovalEmailService $approvalEmail, + SupportActionLogger $logger, + SupportCase $case, + SupportApproval $approval, + string $action, + array $result, + bool $succeeded, + ): void { + try { + $payload = $approvalEmail->sendActionCompletion($case, $approval, $action, $result, $succeeded); + $logger->log( + case: $case, + actionName: 'support_completion_email', + actionType: 'notify', + input: ['approval_id' => $approval->id, 'action' => $action], + output: $payload, + succeeded: (bool) ($payload['ok'] ?? false), + executedBy: 'system', + correlationId: $case->correlation_id, + errorMessage: ($payload['ok'] ?? false) ? null : implode(';', (array) ($payload['errors'] ?? [])), + ); + } catch (\Throwable $e) { + $logger->log( + case: $case, + actionName: 'support_completion_email', + actionType: 'notify', + input: ['approval_id' => $approval->id, 'action' => $action], + output: ['ok' => false, 'error' => $e->getMessage()], + succeeded: false, + executedBy: 'system', + correlationId: $case->correlation_id, + errorMessage: $e->getMessage(), + ); + } } } diff --git a/app/Services/Support/Gmail/GmailIngestService.php b/app/Services/Support/Gmail/GmailIngestService.php index a9d3ca3f3..72f023eff 100644 --- a/app/Services/Support/Gmail/GmailIngestService.php +++ b/app/Services/Support/Gmail/GmailIngestService.php @@ -21,6 +21,7 @@ public function __construct( private readonly SupportActionLogger $logger, private readonly SupportSenderAllowlist $allowlist, private readonly SupportApprovalEmailService $approvalEmail, + private readonly SupportGmailIngestFilter $ingestFilter, ) { } @@ -29,6 +30,8 @@ public function __construct( * ingested: int, * duplicates: int, * skipped_untrusted: int, + * skipped_system: int, + * skipped_non_ticket: int, * approvals_processed: int, * cursor_updated: bool, * warnings: string[] @@ -71,9 +74,16 @@ public function pollAndIngest(int $max = 25): array $ingested = 0; $duplicates = 0; $skippedUntrusted = 0; + $skippedSystem = 0; + $skippedNonTicket = 0; $approvalsProcessed = 0; foreach ($result['messages'] as $msg) { + if ($this->ingestFilter->isSystemOutbound($msg)) { + $skippedSystem++; + continue; + } + if ($this->allowlist->isAllowed($msg->from)) { $approvalResult = $this->approvalEmail->processApprovalReply($msg, (string) $msg->from); if ($approvalResult !== null) { @@ -87,6 +97,11 @@ public function pollAndIngest(int $max = 25): array continue; } + if (!$this->ingestFilter->isTicketSubject($msg->subject)) { + $skippedNonTicket++; + continue; + } + $requester = SupportEmailAddress::fromHeader($msg->from); $case = $this->intake->intake([ @@ -131,6 +146,8 @@ public function pollAndIngest(int $max = 25): array 'ingested' => $ingested, 'duplicates' => $duplicates, 'skipped_untrusted' => $skippedUntrusted, + 'skipped_system' => $skippedSystem, + 'skipped_non_ticket' => $skippedNonTicket, 'approvals_processed' => $approvalsProcessed, 'cursor_updated' => true, 'warnings' => $result['warnings'] ?? [], @@ -141,7 +158,7 @@ public function pollAndIngest(int $max = 25): array } /** - * @return array{ingested: int, duplicates: int, skipped_untrusted: int, approvals_processed: int, cursor_updated: bool, warnings: string[]} + * @return array{ingested: int, duplicates: int, skipped_untrusted: int, skipped_system: int, skipped_non_ticket: int, approvals_processed: int, cursor_updated: bool, warnings: string[]} */ private function emptyResult(): array { @@ -149,6 +166,8 @@ private function emptyResult(): array 'ingested' => 0, 'duplicates' => 0, 'skipped_untrusted' => 0, + 'skipped_system' => 0, + 'skipped_non_ticket' => 0, 'approvals_processed' => 0, 'cursor_updated' => false, 'warnings' => [], diff --git a/app/Services/Support/Gmail/SupportGmailIngestFilter.php b/app/Services/Support/Gmail/SupportGmailIngestFilter.php new file mode 100644 index 000000000..646b46a4c --- /dev/null +++ b/app/Services/Support/Gmail/SupportGmailIngestFilter.php @@ -0,0 +1,29 @@ +from); + $notify = SupportEmailAddress::normalize((string) config('support_gmail.notify_email')); + + return $from !== null && $notify !== null && $from === $notify; + } + + public function isTicketSubject(?string $subject): bool + { + $prefix = strtolower(trim((string) config('support_gmail.subject_prefix', ''))); + if ($prefix === '') { + return false; + } + + return str_contains(strtolower((string) $subject), $prefix); + } +} diff --git a/app/Services/Support/Gmail/SupportGmailPollQuery.php b/app/Services/Support/Gmail/SupportGmailPollQuery.php index 15f711659..b87ac9c0d 100644 --- a/app/Services/Support/Gmail/SupportGmailPollQuery.php +++ b/app/Services/Support/Gmail/SupportGmailPollQuery.php @@ -20,7 +20,13 @@ public static function resolve(): string // Ingest new tickets (codeweek-support) and APPROVE replies (Re: [CW-SUPPORT #…]). $subjectFilters = ['subject:'.self::quoteGmailSearchTerm($prefix)]; if ($approvalPrefix !== '' && !self::sameSearchTerm($prefix, $approvalPrefix)) { - $subjectFilters[] = 'subject:'.self::quoteGmailSearchTerm($approvalPrefix); + $approvalSubject = 'subject:'.self::quoteGmailSearchTerm($approvalPrefix); + $notify = trim((string) config('support_gmail.notify_email')); + if ($notify !== '') { + // Exclude our own dry-run / completion emails from the approval poll. + $approvalSubject .= ' -from:'.self::quoteGmailFromAddress($notify); + } + $subjectFilters[] = $approvalSubject; } $parts[] = count($subjectFilters) === 1 ? $subjectFilters[0] @@ -56,4 +62,9 @@ private static function sameSearchTerm(string $a, string $b): bool { return strcasecmp(trim($a), trim($b)) === 0; } + + private static function quoteGmailFromAddress(string $email): string + { + return self::quoteGmailSearchTerm(trim($email)); + } } diff --git a/app/Services/Support/SupportApprovalEmailService.php b/app/Services/Support/SupportApprovalEmailService.php index b2e5c9dbb..e50f6944e 100644 --- a/app/Services/Support/SupportApprovalEmailService.php +++ b/app/Services/Support/SupportApprovalEmailService.php @@ -49,29 +49,58 @@ public function sendActionCompletion( return SupportJson::ok('support_completion_email', ['case_id' => $case->id], ['skipped' => true]); } - $to = SupportEmailAddress::normalize( - (string) ($approval->notify_email ?: $case->forwarded_by_email ?: config('support_gmail.notify_email')), - ); - if ($to === null) { + $recipients = $this->completionRecipients($case, $approval); + if ($recipients === []) { return SupportJson::fail('support_completion_email', ['case_id' => $case->id], 'no_recipient_email'); } $body = $this->buildCompletionBody($case, $action, $result, $succeeded, (string) ($approval->approved_by ?? '')); + $subject = $this->completionSubject($case, $succeeded); + $sentTo = []; + + foreach ($recipients as $to) { + $sent = $this->gmail->sendPlainText( + to: $to, + subject: $subject, + body: $body, + threadId: $approval->gmail_thread_id, + ); + $sentTo[] = ['to' => $to, 'gmail_message_id' => $sent['id'] ?? null]; + } - $sent = $this->gmail->sendPlainText( - to: $to, - subject: $this->completionSubject($case, $succeeded), - body: $body, - threadId: $approval->gmail_thread_id, - ); - - return SupportJson::ok('support_completion_email', ['case_id' => $case->id, 'to' => $to], [ + return SupportJson::ok('support_completion_email', ['case_id' => $case->id], [ 'succeeded' => $succeeded, - 'gmail_message_id' => $sent['id'] ?? null, - 'gmail_thread_id' => $sent['thread_id'] ?? $approval->gmail_thread_id, + 'sent_to' => $sentTo, + 'gmail_thread_id' => $approval->gmail_thread_id, ]); } + /** + * @return list + */ + private function completionRecipients(SupportCase $case, SupportApproval $approval): array + { + $candidates = [ + SupportEmailAddress::normalize((string) config('support_gmail.notify_email')), + SupportEmailAddress::normalize((string) $approval->notify_email), + SupportEmailAddress::normalize((string) $approval->approved_by), + SupportEmailAddress::normalize((string) $case->forwarded_by_email), + ]; + + $out = []; + foreach ($candidates as $email) { + if ($email === null || $email === '') { + continue; + } + if (!$this->allowlist->isAllowed($email)) { + continue; + } + $out[$email] = $email; + } + + return array_values($out); + } + /** * Send dry-run summary and open a pending approval for email reply. */ diff --git a/tests/Unit/Support/GmailIngestServiceTest.php b/tests/Unit/Support/GmailIngestServiceTest.php index 7cfbd80fe..89540781c 100644 --- a/tests/Unit/Support/GmailIngestServiceTest.php +++ b/tests/Unit/Support/GmailIngestServiceTest.php @@ -31,6 +31,7 @@ private function makeService(GmailConnector $connector): GmailIngestService logger: app(SupportActionLogger::class), allowlist: app(SupportSenderAllowlist::class), approvalEmail: $approvalEmail, + ingestFilter: app(\App\Services\Support\Gmail\SupportGmailIngestFilter::class), ); } @@ -45,6 +46,7 @@ public function test_poll_and_ingest_creates_cases_and_dedupes(): void config()->set('support_gmail.label', 'Support-AI'); config()->set('support_gmail.query', 'newer_than:7d'); config()->set('support_gmail.allowed_sender_domains', ['matrixinternet.ie']); + config()->set('support_gmail.subject_prefix', 'codeweek-support'); $fakeConnector = new class implements GmailConnector { public function fetchNewMessages( @@ -56,9 +58,9 @@ public function fetchNewMessages( ): array { return [ 'messages' => [ - new GmailMessage('m1', 't1', 'Subj 1', 'sender@matrixinternet.ie', "Hello 1"), - new GmailMessage('m1', 't1', 'Subj 1', 'sender@matrixinternet.ie', "Hello 1 DUP"), - new GmailMessage('m2', 't2', 'Subj 2', 'other@matrixinternet.ie', "Hello 2"), + new GmailMessage('m1', 't1', 'codeweek-support ticket 1', 'sender@matrixinternet.ie', "Hello 1"), + new GmailMessage('m1', 't1', 'codeweek-support ticket 1', 'sender@matrixinternet.ie', "Hello 1 DUP"), + new GmailMessage('m2', 't2', 'codeweek-support ticket 2', 'other@matrixinternet.ie', "Hello 2"), ], 'next_history_id' => '123', 'warnings' => [], @@ -140,6 +142,67 @@ public function fetchNewMessages( $lock->release(); } + public function test_poll_skips_system_outbound_and_processes_approve_reply(): void + { + config()->set('support_gmail.enabled', true); + config()->set('support_gmail.lock.name', 'test:support:gmail:poll:approve'); + config()->set('support_gmail.lock.ttl_seconds', 30); + config()->set('support_gmail.notify_email', 'codeweek@matrixinternet.ie'); + config()->set('support_gmail.allowed_sender_domains', ['matrixinternet.ie']); + config()->set('support_gmail.subject_prefix', 'codeweek-support'); + + $approvalEmail = $this->createMock(SupportApprovalEmailService::class); + $approvalEmail->expects($this->once()) + ->method('processApprovalReply') + ->willReturn(['ok' => true, 'tool' => 'support_email_approval']); + + $fakeConnector = new class implements GmailConnector { + public function fetchNewMessages( + string $mailbox, + ?string $label, + string $query, + ?string $sinceHistoryId, + int $max = 25, + ): array { + return [ + 'messages' => [ + new GmailMessage( + 'bot1', + 't-bot', + '[CW-SUPPORT #20] Support copilot - dry run review', + 'codeweek@matrixinternet.ie', + 'dry run body', + ), + new GmailMessage( + 'approve1', + 't-approve', + 'Re: [CW-SUPPORT #20] Support copilot - dry run review', + 'bernard@matrixinternet.ie', + "APPROVE\n", + ), + ], + 'next_history_id' => null, + 'warnings' => [], + ]; + } + }; + + $svc = new GmailIngestService( + connector: $fakeConnector, + intake: app(CaseIntakeService::class), + logger: app(SupportActionLogger::class), + allowlist: app(SupportSenderAllowlist::class), + approvalEmail: $approvalEmail, + ingestFilter: app(\App\Services\Support\Gmail\SupportGmailIngestFilter::class), + ); + + $res = $svc->pollAndIngest(25); + + $this->assertSame(1, $res['approvals_processed']); + $this->assertSame(1, $res['skipped_system']); + $this->assertSame(0, $res['ingested']); + } + public function test_poll_passes_through_connector_warnings(): void { config()->set('support_gmail.enabled', true); diff --git a/tests/Unit/Support/SupportApprovalCompletionEmailTest.php b/tests/Unit/Support/SupportApprovalCompletionEmailTest.php index a219baa8e..004e2d82b 100644 --- a/tests/Unit/Support/SupportApprovalCompletionEmailTest.php +++ b/tests/Unit/Support/SupportApprovalCompletionEmailTest.php @@ -48,15 +48,16 @@ public function test_send_action_completion_calls_gmail(): void ]); $gmail = $this->createMock(GmailOutboundService::class); - $gmail->expects($this->once()) + config()->set('support_gmail.allowed_sender_domains', ['matrixinternet.ie']); + config()->set('support_gmail.notify_email', 'notify@matrixinternet.ie'); + + $gmail->expects($this->exactly(2)) ->method('sendPlainText') - ->with( - 'notify@matrixinternet.ie', - $this->stringContains('action completed'), - $this->stringContains('COMPLETED'), - 'thread-1', - ) - ->willReturn(['id' => 'msg-1', 'thread_id' => 'thread-1']); + ->willReturnCallback(function (string $to) { + $this->assertContains($to, ['notify@matrixinternet.ie', 'admin@matrixinternet.ie']); + + return ['id' => 'msg-'.$to, 'thread_id' => 'thread-1']; + }); $svc = new SupportApprovalEmailService( $gmail, diff --git a/tests/Unit/Support/SupportGmailPollQueryTest.php b/tests/Unit/Support/SupportGmailPollQueryTest.php index eb3bfadbd..15f350cee 100644 --- a/tests/Unit/Support/SupportGmailPollQueryTest.php +++ b/tests/Unit/Support/SupportGmailPollQueryTest.php @@ -12,8 +12,10 @@ public function test_builds_query_with_subject_prefix(): void config()->set('support_gmail.subject_prefix', 'codeweek-support'); config()->set('support_gmail.query', 'newer_than:90d'); + config()->set('support_gmail.notify_email', 'codeweek@matrixinternet.ie'); + $this->assertSame( - '(subject:codeweek-support OR subject:"[CW-SUPPORT") newer_than:90d', + '(subject:codeweek-support OR subject:"[CW-SUPPORT" -from:codeweek@matrixinternet.ie) newer_than:90d', SupportGmailPollQuery::resolve(), ); }