diff --git a/app/Services/Support/Gmail/GoogleGmailConnector.php b/app/Services/Support/Gmail/GoogleGmailConnector.php index 9c469abde..f684c1f16 100644 --- a/app/Services/Support/Gmail/GoogleGmailConnector.php +++ b/app/Services/Support/Gmail/GoogleGmailConnector.php @@ -231,7 +231,7 @@ public function sendPlainTextMessage( $headers = [ 'To: '.$to, - 'Subject: '.$subject, + 'Subject: '.$this->encodeHeaderValue($subject), 'MIME-Version: 1.0', 'Content-Type: text/plain; charset=UTF-8', ]; @@ -244,6 +244,31 @@ public function sendPlainTextMessage( $raw = implode("\r\n", $headers)."\r\n\r\n".$body; $encoded = rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + return $this->dispatch($mailbox, $encoded, $threadId); + } + + /** + * RFC 2047 encode a header value when it contains non-ASCII bytes so that + * characters like em-dashes and accents are not mangled by mail clients. + */ + private function encodeHeaderValue(string $value): string + { + if (preg_match('/[^\x20-\x7E]/', $value) !== 1) { + return $value; + } + + $encoded = mb_encode_mimeheader($value, 'UTF-8', 'B', "\r\n"); + + return $encoded !== false && $encoded !== '' + ? $encoded + : '=?UTF-8?B?'.base64_encode($value).'?='; + } + + /** + * @return array{id: string, thread_id: ?string} + */ + private function dispatch(string $mailbox, string $encoded, ?string $threadId): array + { $message = new GoogleMessage(); $message->setRaw($encoded); diff --git a/app/Services/Support/SupportApprovalEmailService.php b/app/Services/Support/SupportApprovalEmailService.php index fe2e0aa66..61e0eb19a 100644 --- a/app/Services/Support/SupportApprovalEmailService.php +++ b/app/Services/Support/SupportApprovalEmailService.php @@ -584,7 +584,8 @@ private function roleItemLine(array $item): string $status = (string) ($item['status'] ?? ''); $label = match ($status) { - 'would_add', 'added' => 'will be added', + 'would_add' => 'will be added', + 'added' => 'role added', 'already_has_role' => 'already has this role (no change)', 'user_not_found' => 'no CodeWeek account found — skipped', 'ambiguous' => 'multiple accounts match — needs manual check, skipped', diff --git a/tests/Unit/Support/SupportCompletionEmailCopyTest.php b/tests/Unit/Support/SupportCompletionEmailCopyTest.php index dd2701235..97056275a 100644 --- a/tests/Unit/Support/SupportCompletionEmailCopyTest.php +++ b/tests/Unit/Support/SupportCompletionEmailCopyTest.php @@ -82,6 +82,78 @@ public function test_completion_body_uses_plain_language_for_profile_update(): v $this->assertStringNotContainsString('COMPLETED', $capturedBody); } + public function test_completion_body_for_role_add_uses_past_tense_per_account(): void + { + $case = SupportCase::create([ + 'source_channel' => 'gmail', + 'processing_mode' => 'automated', + 'subject' => 'codeweek-support — add leading teacher role', + 'raw_message' => 'add role: leading teacher', + 'target_email' => 'a@example.com', + 'secondary_emails' => ['b@example.com', 'c@example.com'], + 'case_type' => 'role_add', + 'status' => 'verified', + 'risk_level' => 'low', + 'correlation_id' => 'cid-role', + ]); + + $approval = SupportApproval::create([ + 'support_case_id' => $case->id, + 'requested_action' => 'user_role_add', + 'payload_json' => ['operation' => 'add', 'role' => 'leading teacher', 'emails' => ['a@example.com', 'b@example.com', 'c@example.com']], + 'risk_level' => 'low', + 'status' => 'approved', + 'approved_by' => 'admin@matrixinternet.ie', + 'approved_at' => now(), + ]); + + config([ + 'support_gmail.send_completion_email' => true, + 'support_gmail.allowed_sender_domains' => ['matrixinternet.ie'], + 'support_gmail.notify_email' => 'notify@matrixinternet.ie', + ]); + + $capturedBody = null; + $gmail = $this->createMock(GmailOutboundService::class); + $gmail->method('sendPlainText')->willReturnCallback(function ($to, $subject, $body) use (&$capturedBody) { + $capturedBody ??= $body; + + return ['id' => 'msg-1', 'thread_id' => 't1']; + }); + + $svc = new SupportApprovalEmailService( + $gmail, + app(SupportSenderAllowlist::class), + app(SupportProfileRequestParser::class), + app(\App\Services\Support\SupportRoleRequestResolver::class), + ); + + $svc->sendActionCompletion( + $case, + $approval, + 'user_role_add', + [ + 'ok' => true, + 'result' => [ + 'role' => 'leading teacher', + 'summary' => ['added' => 2, 'would_add' => 0, 'already_has_role' => 1, 'user_not_found' => 0, 'ambiguous' => 0], + 'items' => [ + ['email' => 'a@example.com', 'status' => 'added', 'user_id' => 1, 'matched_user_ids' => [1]], + ['email' => 'b@example.com', 'status' => 'added', 'user_id' => 2, 'matched_user_ids' => [2]], + ['email' => 'c@example.com', 'status' => 'already_has_role', 'user_id' => 3, 'matched_user_ids' => [3]], + ], + ], + ], + true, + ); + + $this->assertNotNull($capturedBody); + $this->assertStringContainsString('We added the role "leading teacher" to 2 CodeWeek accounts.', $capturedBody); + $this->assertStringContainsString('a@example.com — role added', $capturedBody); + $this->assertStringContainsString('c@example.com — already has this role', $capturedBody); + $this->assertStringNotContainsString('will be added', $capturedBody); + } + public function test_completion_subject_for_account_restore(): void { $case = new SupportCase(['id' => 5]);