diff --git a/app/Console/Commands/Support/GmailAuthorizeCommand.php b/app/Console/Commands/Support/GmailAuthorizeCommand.php index 7842140b2..53c18a424 100644 --- a/app/Console/Commands/Support/GmailAuthorizeCommand.php +++ b/app/Console/Commands/Support/GmailAuthorizeCommand.php @@ -2,6 +2,7 @@ namespace App\Console\Commands\Support; +use App\Services\Support\Gmail\GmailOAuthConfig; use Google\Client as GoogleClient; use Google\Service\Gmail as GmailService; use Illuminate\Console\Command; @@ -18,22 +19,22 @@ class GmailAuthorizeCommand extends Command public function handle(): int { - $credentialsPath = (string) ($this->option('credentials') ?: config('support_gmail.credentials_json')); - $tokenPath = (string) ($this->option('token') ?: config('support_gmail.token_json')); - - if (!$credentialsPath || !is_file($credentialsPath)) { - $this->error('OAuth credentials JSON not found. Set SUPPORT_GMAIL_CREDENTIALS_JSON or pass --credentials='); - return self::FAILURE; - } - if (!$tokenPath) { - $this->error('Token path not set. Set SUPPORT_GMAIL_TOKEN_JSON or pass --token='); - return self::FAILURE; - } + $credentialsPath = $this->option('credentials'); + $tokenPath = $this->option('token') ?: config('support_gmail.token_json'); $client = new GoogleClient(); $client->setApplicationName('Codeweek Internal Support Copilot'); $client->setScopes([GmailService::GMAIL_READONLY]); - $client->setAuthConfig($credentialsPath); + if ($credentialsPath) { + if (!is_file((string) $credentialsPath)) { + $this->error('OAuth credentials file not found: '.$credentialsPath); + + return self::FAILURE; + } + $client->setAuthConfig((string) $credentialsPath); + } else { + GmailOAuthConfig::applyClientSecrets($client); + } $client->setAccessType('offline'); $client->setPrompt('consent'); @@ -62,12 +63,16 @@ public function handle(): int return self::FAILURE; } - // Ensure folder exists and write token json. - File::ensureDirectoryExists(dirname($tokenPath)); - File::put($tokenPath, json_encode($token, JSON_PRETTY_PRINT)); - @chmod($tokenPath, 0600); + if ($tokenPath) { + File::ensureDirectoryExists(dirname((string) $tokenPath)); + File::put((string) $tokenPath, json_encode($token, JSON_PRETTY_PRINT)); + @chmod((string) $tokenPath, 0600); + $this->info('Token saved to '.$tokenPath); + } else { + $this->warn('No SUPPORT_GMAIL_TOKEN_JSON / --token path — paste this JSON into Forge as SUPPORT_GMAIL_TOKEN:'); + $this->line(json_encode($token, JSON_PRETTY_PRINT)); + } - $this->info('Token saved to '.$tokenPath); $this->line('Next: set SUPPORT_GMAIL_ENABLED=true and run `php artisan support:gmail:poll`.'); return self::SUCCESS; diff --git a/app/Console/Commands/Support/UserUpdateEmailCommand.php b/app/Console/Commands/Support/UserUpdateEmailCommand.php new file mode 100644 index 000000000..bd41eb2f5 --- /dev/null +++ b/app/Console/Commands/Support/UserUpdateEmailCommand.php @@ -0,0 +1,156 @@ +normalizeEmail((string) $this->argument('from')); + $to = $this->normalizeEmail((string) $this->argument('to')); + $dryRun = (bool) $this->option('dry-run'); + + $input = [ + 'from' => $from, + 'to' => $to, + 'dry_run' => $dryRun, + ]; + + $case = SupportCase::create([ + 'source_channel' => 'manual', + 'processing_mode' => 'manual', + 'subject' => 'CLI: support:user-update-email', + 'raw_message' => 'CLI invocation', + 'normalized_message' => null, + 'status' => 'investigating', + 'risk_level' => 'high', + 'correlation_id' => SupportJson::correlationId(), + ]); + + try { + if (!$this->isValidEmail($from)) { + throw new \InvalidArgumentException('Invalid FROM email.'); + } + if (!$this->isValidEmail($to)) { + throw new \InvalidArgumentException('Invalid TO email.'); + } + if ($from === $to) { + throw new \InvalidArgumentException('FROM and TO emails are identical.'); + } + + /** @var \Illuminate\Database\Eloquent\Collection $matches */ + $matches = User::withTrashed() + ->whereRaw('LOWER(email) = ?', [$from]) + ->orWhereRaw('LOWER(email_display) = ?', [$from]) + ->get(); + + if ($matches->count() === 0) { + $payload = SupportJson::fail('user_update_email', $input, 'No user found for FROM email (email or email_display).'); + $this->output->writeln(json_encode($payload, JSON_UNESCAPED_SLASHES)); + return self::FAILURE; + } + + if ($matches->count() > 1) { + $payload = SupportJson::fail('user_update_email', $input, [ + 'Multiple users match FROM email; refusing to update.', + 'Matches: '.implode(', ', $matches->map(fn (User $u) => (string) $u->id)->all()), + ]); + $this->output->writeln(json_encode($payload, JSON_UNESCAPED_SLASHES)); + return self::FAILURE; + } + + $user = $matches->first(); + if (!$user) { + throw new \RuntimeException('Unexpected: missing matched user.'); + } + + $conflict = User::withTrashed() + ->where('id', '<>', $user->id) + ->where(function ($q) use ($to) { + $q->whereRaw('LOWER(email) = ?', [$to]) + ->orWhereRaw('LOWER(email_display) = ?', [$to]); + }) + ->exists(); + + if ($conflict) { + $payload = SupportJson::fail('user_update_email', $input, 'TO email already exists on another user (email or email_display).'); + $this->output->writeln(json_encode($payload, JSON_UNESCAPED_SLASHES)); + return self::FAILURE; + } + + $before = [ + 'id' => $user->id, + 'email' => $user->email, + 'email_display' => $user->email_display, + 'deleted_at' => $user->deleted_at?->toISOString(), + 'email_verified_at' => optional($user->email_verified_at)->toISOString(), + ]; + + $wouldUpdateEmailDisplay = ($this->normalizeEmail((string) ($user->email_display ?? '')) === $from); + + if (!$dryRun) { + DB::transaction(function () use ($user, $to, $wouldUpdateEmailDisplay) { + $user->email = $to; + if ($wouldUpdateEmailDisplay) { + $user->email_display = $to; + } + + // Email changed: require re-verification in case this is used for auth flows. + if (property_exists($user, 'email_verified_at')) { + $user->email_verified_at = null; + } + + $user->save(); + }); + + $user->refresh(); + } + + $after = [ + 'id' => $user->id, + 'email' => $dryRun ? $to : $user->email, + 'email_display' => $dryRun + ? ($wouldUpdateEmailDisplay ? $to : $user->email_display) + : $user->email_display, + 'email_verified_at' => $dryRun ? null : optional($user->email_verified_at)->toISOString(), + ]; + + $result = [ + 'support_case_id' => $case->id, + 'updated' => !$dryRun, + 'would_update_email_display' => $wouldUpdateEmailDisplay, + 'before' => $before, + 'after' => $after, + ]; + + $payload = SupportJson::ok('user_update_email', $input, $result); + } catch (\Throwable $e) { + $payload = SupportJson::fail('user_update_email', $input, $e->getMessage()); + } + + $this->output->writeln(json_encode($payload, JSON_UNESCAPED_SLASHES)); + + return $payload['ok'] ? self::SUCCESS : self::FAILURE; + } + + private function normalizeEmail(string $email): string + { + return strtolower(trim($email)); + } + + private function isValidEmail(string $email): bool + { + return filter_var($email, FILTER_VALIDATE_EMAIL) !== false; + } +} + diff --git a/app/Services/Support/Gmail/GmailOAuthConfig.php b/app/Services/Support/Gmail/GmailOAuthConfig.php new file mode 100644 index 000000000..29aad3dad --- /dev/null +++ b/app/Services/Support/Gmail/GmailOAuthConfig.php @@ -0,0 +1,67 @@ +setAuthConfig($decoded); + + return; + } + + $path = config('support_gmail.credentials_json'); + if ($path && is_file($path)) { + $client->setAuthConfig($path); + + return; + } + + if ($path) { + throw new \RuntimeException( + 'Gmail OAuth credentials file not found: '.$path.'. Set SUPPORT_GMAIL_CREDENTIALS (paste client JSON in env) or upload the file to that path.' + ); + } + + throw new \RuntimeException( + 'Gmail OAuth credentials missing. Set SUPPORT_GMAIL_CREDENTIALS (client JSON) or SUPPORT_GMAIL_CREDENTIALS_JSON (path to client JSON).' + ); + } + + public static function applyAccessToken(GoogleClient $client): void + { + $inline = config('support_gmail.token'); + if (self::nonEmptyString($inline)) { + $decoded = json_decode($inline, true); + if (!is_array($decoded)) { + throw new \RuntimeException('SUPPORT_GMAIL_TOKEN must be valid JSON.'); + } + $client->setAccessToken($decoded); + + return; + } + + $tokenJson = config('support_gmail.token_json'); + if ($tokenJson && is_file($tokenJson)) { + $client->setAccessToken(json_decode((string) file_get_contents($tokenJson), true)); + } + } + + private static function nonEmptyString(mixed $v): bool + { + return is_string($v) && trim($v) !== ''; + } +} diff --git a/app/Services/Support/Gmail/GoogleGmailConnector.php b/app/Services/Support/Gmail/GoogleGmailConnector.php index a1107bbfe..cf9bc3e0a 100644 --- a/app/Services/Support/Gmail/GoogleGmailConnector.php +++ b/app/Services/Support/Gmail/GoogleGmailConnector.php @@ -20,18 +20,8 @@ public function __construct() $client->setScopes([GmailService::GMAIL_READONLY]); $client->setAccessType('offline'); - $credentials = config('support_gmail.credentials_json'); - if (!$credentials) { - throw new \RuntimeException('SUPPORT_GMAIL_CREDENTIALS_JSON not set'); - } - - $client->setAuthConfig($credentials); - - // Optional OAuth token json (installed-app flows). - $tokenJson = config('support_gmail.token_json'); - if ($tokenJson && is_file($tokenJson)) { - $client->setAccessToken(json_decode((string) file_get_contents($tokenJson), true)); - } + GmailOAuthConfig::applyClientSecrets($client); + GmailOAuthConfig::applyAccessToken($client); $this->client = $client; $this->gmail = new GmailService($client); @@ -123,7 +113,7 @@ private function ensureValidToken(): void { $token = $this->client->getAccessToken(); if (empty($token)) { - throw new \RuntimeException('Gmail token missing. Run support:gmail:authorize and set SUPPORT_GMAIL_TOKEN_JSON.'); + throw new \RuntimeException('Gmail token missing. Run support:gmail:authorize and set SUPPORT_GMAIL_TOKEN or SUPPORT_GMAIL_TOKEN_JSON.'); } if (!$this->client->isAccessTokenExpired()) { diff --git a/config/support_gmail.php b/config/support_gmail.php index 670d622dd..b7258bf49 100644 --- a/config/support_gmail.php +++ b/config/support_gmail.php @@ -19,10 +19,16 @@ // Example: 'is:unread newer_than:7d -category:promotions' 'query' => env('SUPPORT_GMAIL_QUERY', 'newer_than:7d'), - // Google service account or OAuth client credentials JSON path. + // Google OAuth client JSON: paste full JSON from Google Cloud (preferred on Forge; survives deploys). + 'credentials' => env('SUPPORT_GMAIL_CREDENTIALS'), + + // Alternative: path to the same JSON on disk (e.g. storage/app/google/support-gmail-credentials.json). 'credentials_json' => env('SUPPORT_GMAIL_CREDENTIALS_JSON', null), - // Token JSON path for OAuth installed-app flows (if used). + // OAuth token JSON: paste token from support:gmail:authorize (preferred on Forge). + 'token' => env('SUPPORT_GMAIL_TOKEN'), + + // Alternative: path to token JSON (e.g. storage/app/google/support-gmail-token.json). 'token_json' => env('SUPPORT_GMAIL_TOKEN_JSON', null), // When true, mark ingested messages as read and/or apply a label.