From 1e9a40356763490adcb1c25ba352cf7499417e50 Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Tue, 19 May 2026 16:42:10 +0100 Subject: [PATCH 1/2] fix(support): poll Gmail for [CW-SUPPORT] approval reply threads APPROVE replies use Re: [CW-SUPPORT #n] subjects without codeweek-support, so they were never ingested. Extend the poll query to OR in the approval prefix. Co-authored-by: Cursor --- .../Support/Gmail/SupportGmailPollQuery.php | 17 +++++++++++++++-- docs/support-copilot-allowed-actions.md | 3 ++- .../Unit/Support/SupportGmailPollQueryTest.php | 15 ++++++++++++++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/app/Services/Support/Gmail/SupportGmailPollQuery.php b/app/Services/Support/Gmail/SupportGmailPollQuery.php index 58a55e0c8..15f711659 100644 --- a/app/Services/Support/Gmail/SupportGmailPollQuery.php +++ b/app/Services/Support/Gmail/SupportGmailPollQuery.php @@ -13,10 +13,18 @@ public static function resolve(): string $parts = []; $prefix = trim((string) config('support_gmail.subject_prefix', '')); + $approvalPrefix = trim((string) config('support_gmail.approval_subject_prefix', '[CW-SUPPORT')); $baseQuery = trim((string) config('support_gmail.query', 'newer_than:90d')); if ($prefix !== '' && !self::queryContainsSubjectFilter($baseQuery)) { - $parts[] = 'subject:'.self::quoteGmailSearchTerm($prefix); + // 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); + } + $parts[] = count($subjectFilters) === 1 + ? $subjectFilters[0] + : '('.implode(' OR ', $subjectFilters).')'; } if ($baseQuery !== '') { @@ -37,10 +45,15 @@ private static function queryContainsSubjectFilter(string $query): bool private static function quoteGmailSearchTerm(string $term): string { - if (preg_match('/[\s"\']/', $term)) { + if (preg_match('/[\s"\'\[\]#]/', $term)) { return '"'.str_replace('"', '', $term).'"'; } return $term; } + + private static function sameSearchTerm(string $a, string $b): bool + { + return strcasecmp(trim($a), trim($b)) === 0; + } } diff --git a/docs/support-copilot-allowed-actions.md b/docs/support-copilot-allowed-actions.md index 90e85f388..146b10f8b 100644 --- a/docs/support-copilot-allowed-actions.md +++ b/docs/support-copilot-allowed-actions.md @@ -12,7 +12,8 @@ This document lists everything the support copilot **allows**, **automates**, an |------|---------| | Sender email domain | `@matrixinternet.ie`, `@codeweek.eu` (config: `SUPPORT_GMAIL_ALLOWED_DOMAINS`) | | Extra explicit senders | Optional list via `SUPPORT_GMAIL_ALLOWED_SENDERS` | -| Subject line | Must contain `codeweek-support` (config: `SUPPORT_GMAIL_SUBJECT_PREFIX`) | +| Subject line (new tickets) | Must contain `codeweek-support` (config: `SUPPORT_GMAIL_SUBJECT_PREFIX`) | +| Subject line (APPROVE replies) | Must be in the **`[CW-SUPPORT #…]`** thread (poll also searches this prefix) | | Teacher / parent Gmail, schools, etc. | **Not ingested** — staff must send a new email from an allowed domain | --- diff --git a/tests/Unit/Support/SupportGmailPollQueryTest.php b/tests/Unit/Support/SupportGmailPollQueryTest.php index 95787e021..eb3bfadbd 100644 --- a/tests/Unit/Support/SupportGmailPollQueryTest.php +++ b/tests/Unit/Support/SupportGmailPollQueryTest.php @@ -13,11 +13,24 @@ public function test_builds_query_with_subject_prefix(): void config()->set('support_gmail.query', 'newer_than:90d'); $this->assertSame( - 'subject:codeweek-support newer_than:90d', + '(subject:codeweek-support OR subject:"[CW-SUPPORT") newer_than:90d', SupportGmailPollQuery::resolve(), ); } + public function test_includes_approval_reply_subject_for_poll(): void + { + config()->set('support_gmail.subject_prefix', 'codeweek-support'); + config()->set('support_gmail.approval_subject_prefix', '[CW-SUPPORT'); + config()->set('support_gmail.query', 'newer_than:90d'); + + $query = SupportGmailPollQuery::resolve(); + + $this->assertStringContainsString('codeweek-support', $query); + $this->assertStringContainsString('[CW-SUPPORT', $query); + $this->assertStringContainsString(' OR ', $query); + } + public function test_skips_duplicate_subject_filter_when_query_already_has_one(): void { config()->set('support_gmail.subject_prefix', 'codeweek-support'); From b95cf0c794a34cb7d17bf7ae8eecbb9d25ca51d9 Mon Sep 17 00:00:00 2001 From: bernardhanna Date: Tue, 19 May 2026 16:44:36 +0100 Subject: [PATCH 2/2] feat(support): send completion email after APPROVE runs Notify the team when an approved action succeeds or fails, in the same Gmail thread as the dry-run summary. Configurable via SUPPORT_GMAIL_SEND_COMPLETION_EMAIL. Co-authored-by: Cursor --- .../ExecuteApprovedSupportActionJob.php | 8 +- .../Support/SupportApprovalEmailService.php | 96 +++++++++++++++++++ config/support_gmail.php | 3 + docs/support-copilot-allowed-actions.md | 13 ++- docs/support-copilot-stakeholder-guide.md | 3 + .../SupportApprovalCompletionEmailTest.php | 84 ++++++++++++++++ 6 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Support/SupportApprovalCompletionEmailTest.php diff --git a/app/Jobs/Support/ExecuteApprovedSupportActionJob.php b/app/Jobs/Support/ExecuteApprovedSupportActionJob.php index a0a240a23..a92db8820 100644 --- a/app/Jobs/Support/ExecuteApprovedSupportActionJob.php +++ b/app/Jobs/Support/ExecuteApprovedSupportActionJob.php @@ -5,6 +5,7 @@ use App\Models\Support\SupportApproval; use App\Models\Support\SupportCase; use App\Services\Support\SupportActionLogger; +use App\Services\Support\SupportApprovalEmailService; use App\Services\Support\UserProfileUpdateService; use App\Services\Support\UserRestoreService; use Illuminate\Bus\Queueable; @@ -24,6 +25,7 @@ public function __construct(public int $supportApprovalId) public function handle( UserRestoreService $userRestore, UserProfileUpdateService $userProfileUpdate, + SupportApprovalEmailService $approvalEmail, SupportActionLogger $logger, ): void { @@ -50,17 +52,19 @@ public function handle( if ($action === 'none') { $case->update(['status' => 'resolved']); + $noneResult = ['ok' => true, 'tool' => 'none', 'input' => $payload, 'result' => ['note' => 'no_write_action'], 'errors' => []]; $logger->log( case: $case, actionName: 'approved_action_executed', actionType: 'read', input: ['approval_id' => $approval->id, 'action' => $action], - output: ['ok' => true, 'note' => 'no_write_action'], + output: $noneResult, succeeded: true, executedBy: 'system', approvedBy: $approval->approved_by, correlationId: $case->correlation_id, ); + $approvalEmail->sendActionCompletion($case, $approval, $action, $noneResult, true); return; } @@ -109,6 +113,8 @@ public function handle( correlationId: $case->correlation_id, errorMessage: $ok ? null : implode(';', (array) ($result['errors'] ?? [])), ); + + $approvalEmail->sendActionCompletion($case, $approval, $action, $result ?? [], $ok); } } diff --git a/app/Services/Support/SupportApprovalEmailService.php b/app/Services/Support/SupportApprovalEmailService.php index 881c4f5bc..1ac552679 100644 --- a/app/Services/Support/SupportApprovalEmailService.php +++ b/app/Services/Support/SupportApprovalEmailService.php @@ -25,6 +25,53 @@ public function approvalSubject(SupportCase $case): string return sprintf('%s #%d] Support copilot - dry run review', $prefix, $case->id); } + public function completionSubject(SupportCase $case, bool $succeeded): string + { + $prefix = (string) config('support_gmail.approval_subject_prefix', '[CW-SUPPORT'); + $label = $succeeded ? 'action completed' : 'action failed'; + + return sprintf('%s #%d] Support copilot - %s', $prefix, $case->id, $label); + } + + /** + * Follow-up email after APPROVE is processed (success or failure). + * + * @param array $result + */ + public function sendActionCompletion( + SupportCase $case, + SupportApproval $approval, + string $action, + array $result, + bool $succeeded, + ): array { + if (!config('support_gmail.send_completion_email', true)) { + 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) { + return SupportJson::fail('support_completion_email', ['case_id' => $case->id], 'no_recipient_email'); + } + + $body = $this->buildCompletionBody($case, $action, $result, $succeeded, (string) ($approval->approved_by ?? '')); + + $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], [ + 'succeeded' => $succeeded, + 'gmail_message_id' => $sent['id'] ?? null, + 'gmail_thread_id' => $sent['thread_id'] ?? $approval->gmail_thread_id, + ]); + } + /** * Send dry-run summary and open a pending approval for email reply. */ @@ -241,6 +288,8 @@ private function buildDryRunBody(SupportCase $case, array $proposedAction): stri $lines[] = 'To execute this change, reply to this email with a single line:'; $lines[] = 'APPROVE'; $lines[] = ''; + $lines[] = 'You will receive a follow-up email when the action has run (completed or failed).'; + $lines[] = ''; $lines[] = '(Only @matrixinternet.ie and @codeweek.eu senders are accepted.)'; } else { $lines[] = 'No automated write action proposed. Review the case in Nova if needed.'; @@ -248,4 +297,51 @@ private function buildDryRunBody(SupportCase $case, array $proposedAction): stri return implode("\n", $lines); } + + /** + * @param array $result + */ + private function buildCompletionBody( + SupportCase $case, + string $action, + array $result, + bool $succeeded, + string $approvedBy, + ): string { + $lines = [ + 'CodeWeek Support Copilot - action result', + '', + 'Case #'.$case->id, + 'Status: '.($succeeded ? 'COMPLETED' : 'FAILED'), + 'Action: '.$action, + 'Approved by: '.($approvedBy !== '' ? $approvedBy : '(unknown)'), + 'Case status: '.($case->status ?? 'unknown'), + '', + ]; + + $inner = is_array($result['result'] ?? null) ? $result['result'] : []; + if (isset($inner['before'], $inner['after']) && is_array($inner['before']) && is_array($inner['after'])) { + $lines[] = 'Before: '.json_encode($inner['before'], JSON_UNESCAPED_SLASHES); + $lines[] = 'After: '.json_encode($inner['after'], JSON_UNESCAPED_SLASHES); + $lines[] = ''; + } + + $errors = array_values(array_filter((array) ($result['errors'] ?? []))); + if ($errors !== []) { + $lines[] = 'Errors:'; + foreach ($errors as $error) { + $lines[] = '- '.$error; + } + $lines[] = ''; + } + + if ($succeeded) { + $lines[] = 'The approved change has been applied. No further reply is required.'; + } else { + $lines[] = 'The change was not applied. Review the case in Nova or contact the technical team.'; + $lines[] = 'Include case #'.$case->id.' when escalating.'; + } + + return implode("\n", $lines); + } } diff --git a/config/support_gmail.php b/config/support_gmail.php index e7c2869ac..a5fc410e4 100644 --- a/config/support_gmail.php +++ b/config/support_gmail.php @@ -75,5 +75,8 @@ 'user_restore', 'user_profile_update', ], + + // Send a follow-up email after an APPROVE action runs (success or failure). + 'send_completion_email' => env('SUPPORT_GMAIL_SEND_COMPLETION_EMAIL', true), ]; diff --git a/docs/support-copilot-allowed-actions.md b/docs/support-copilot-allowed-actions.md index 146b10f8b..9b5e2426b 100644 --- a/docs/support-copilot-allowed-actions.md +++ b/docs/support-copilot-allowed-actions.md @@ -99,7 +99,18 @@ Configured in `config/support_gmail.php` → `allowed_write_actions`. | Scope | Purpose | |-------|---------| | `gmail.readonly` | Poll inbox, read approval replies | -| `gmail.send` | Send dry-run summaries | +| `gmail.send` | Send dry-run summaries and completion emails | + +--- + +## 10. Email notifications (what you receive) + +| When | Email subject (example) | +|------|-------------------------| +| Ticket processed (dry-run) | `[CW-SUPPORT #10] Support copilot - dry run review` | +| After you reply **APPROVE** and action runs | `[CW-SUPPORT #10] Support copilot - action completed` or `action failed` | + +Completion emails go to the same recipient as the dry-run summary (`SUPPORT_GMAIL_NOTIFY_EMAIL` unless overridden). Disable with `SUPPORT_GMAIL_SEND_COMPLETION_EMAIL=false`. --- diff --git a/docs/support-copilot-stakeholder-guide.md b/docs/support-copilot-stakeholder-guide.md index 08dfc2705..8f7a28b57 100644 --- a/docs/support-copilot-stakeholder-guide.md +++ b/docs/support-copilot-stakeholder-guide.md @@ -363,6 +363,9 @@ Read the summary email carefully. (`YES` or `PROCEED` also work.) 3. Send from **`@matrixinternet.ie`** or **`@codeweek.eu`** 4. Wait up to ~5 minutes for the system to process your reply +5. You will receive a **follow-up email** in the same thread: + - **`action completed`** — change was applied + - **`action failed`** — change was not applied (see errors in the email; check Nova) --- diff --git a/tests/Unit/Support/SupportApprovalCompletionEmailTest.php b/tests/Unit/Support/SupportApprovalCompletionEmailTest.php new file mode 100644 index 000000000..a219baa8e --- /dev/null +++ b/tests/Unit/Support/SupportApprovalCompletionEmailTest.php @@ -0,0 +1,84 @@ + 10]); + $svc = app(SupportApprovalEmailService::class); + + $this->assertStringContainsString('action completed', $svc->completionSubject($case, true)); + $this->assertStringContainsString('action failed', $svc->completionSubject($case, false)); + $this->assertStringContainsString('#10', $svc->completionSubject($case, true)); + } + + public function test_send_action_completion_calls_gmail(): void + { + config(['support_gmail.send_completion_email' => true]); + + $case = SupportCase::create([ + 'source_channel' => 'manual', + 'processing_mode' => 'manual', + 'subject' => 'test', + 'raw_message' => 'test', + 'status' => 'verified', + 'risk_level' => 'low', + 'correlation_id' => 'cid', + ]); + + $approval = SupportApproval::create([ + 'support_case_id' => $case->id, + 'requested_action' => 'user_profile_update', + 'payload_json' => ['email' => 'u@example.com'], + 'risk_level' => 'low', + 'status' => 'approved', + 'approved_by' => 'admin@matrixinternet.ie', + 'notify_email' => 'notify@matrixinternet.ie', + 'gmail_thread_id' => 'thread-1', + ]); + + $gmail = $this->createMock(GmailOutboundService::class); + $gmail->expects($this->once()) + ->method('sendPlainText') + ->with( + 'notify@matrixinternet.ie', + $this->stringContains('action completed'), + $this->stringContains('COMPLETED'), + 'thread-1', + ) + ->willReturn(['id' => 'msg-1', 'thread_id' => 'thread-1']); + + $svc = new SupportApprovalEmailService( + $gmail, + app(SupportSenderAllowlist::class), + app(SupportProfileRequestParser::class), + ); + + $payload = $svc->sendActionCompletion( + $case, + $approval, + 'user_profile_update', + [ + 'ok' => true, + 'result' => [ + 'before' => ['firstname' => 'A'], + 'after' => ['firstname' => 'B'], + ], + 'errors' => [], + ], + true, + ); + + $this->assertTrue($payload['ok']); + } +}