Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 22 additions & 17 deletions app/Console/Commands/Support/GmailAuthorizeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');

Expand Down Expand Up @@ -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;
Expand Down
156 changes: 156 additions & 0 deletions app/Console/Commands/Support/UserUpdateEmailCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?php

namespace App\Console\Commands\Support;

use App\Models\Support\SupportCase;
use App\Services\Support\SupportJson;
use App\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

class UserUpdateEmailCommand extends Command
{
protected $signature = 'support:user-update-email {from} {to} {--dry-run} {--json}';

protected $description = 'Support tool: update a user email address (dry-run supported)';

public function handle(): int
{
$from = $this->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<int, User> $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;
}
}

67 changes: 67 additions & 0 deletions app/Services/Support/Gmail/GmailOAuthConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

namespace App\Services\Support\Gmail;

use Google\Client as GoogleClient;

/**
* Loads OAuth client credentials and tokens from env (inline JSON) or disk paths.
* Use SUPPORT_GMAIL_CREDENTIALS + SUPPORT_GMAIL_TOKEN on Forge so deploys do not rely on gitignored files.
*/
final class GmailOAuthConfig
{
public static function applyClientSecrets(GoogleClient $client): void
{
$inline = config('support_gmail.credentials');
if (self::nonEmptyString($inline)) {
$decoded = json_decode($inline, true);
if (!is_array($decoded)) {
throw new \RuntimeException('SUPPORT_GMAIL_CREDENTIALS must be valid JSON (Google OAuth client secret JSON).');
}
$client->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) !== '';
}
}
16 changes: 3 additions & 13 deletions app/Services/Support/Gmail/GoogleGmailConnector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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()) {
Expand Down
10 changes: 8 additions & 2 deletions config/support_gmail.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading