Enhance Radar functionality: add sync route and method for Gmail allowlist, update documentation on Git usage, and improve UI for sync feedback. Ensure agent rules are clear regarding commit and push restrictions.

This commit is contained in:
2026-08-21 09:03:26 -04:00
parent 7f9749ed29
commit d2488df4d9
15 changed files with 647 additions and 18 deletions

View File

@ -29,6 +29,9 @@ Windows --push--> Gitea(dev) --webhook--> serveur (deploy_dev)
+--miroir push--> Bitbucket(dev)
```
**Agent : jamais de `git push`.** L'utilisateur synchronise (voir `00-jamais-push.mdc`).
## Déploiement (règles absolues)
- Script shell hors webroot : typiquement `~/deploy/deploy_dev.sh`

View File

@ -0,0 +1,34 @@
---
description: INTERDIT — l'agent ne commit / push / deploy jamais tout seul
alwaysApply: true
---
# Jamais de commit / push / deploy — l'utilisateur pilote Git
Règle absolue. Pas d'exception « pour aider », « cest urgent », ou « pour que ça marche sur le serveur ».
## Interdit (sauf demande explicite de l'utilisateur dans le message)
- `git commit` (toutes formes)
- `git push` (tous remotes)
- Déclencher `deploy_dev.php`, webhook, ou `deploy_dev.sh`
- « Sync Changes » / publier à la place de l'utilisateur
## Comportement par défaut
1. Modifier les fichiers **en local** si la tâche le demande.
2. Dire clairement **quels fichiers** ont changé et **quoi** faire (commit / sync).
3. **S'arrêter** — l'utilisateur commit et synchronise lui-même.
## Autorisé seulement si l'utilisateur le dit clairement
Exemples qui autorisent : « commit », « fais le commit », « commit ça », « pousse », « sync », « déploie ».
- S'il demande **seulement** commit → commit, **pas** de push.
- S'il demande push / sync / deploy → seulement ce qu'il a demandé (et rappeler le modèle utilisateur-sync si la règle push s'applique encore).
Ambigu (« mets ça en ligne », « arrange Git ») → **demander** avant d'exécuter.
## Pourquoi
L'utilisateur doit **voir** chaque commit. Un agent qui commit/push en silence = perte de contrôle et risque de mélange DEV/prod.

View File

@ -7,6 +7,11 @@ alwaysApply: true
Référentiel agent — pas une doc humaine. S'appliquer à tous les projets Progiweb montés sur ce modèle.
## Git
**Jamais de commit, push ou deploy** sauf demande explicite. Voir `entreprise/00-jamais-push.mdc`.
L'utilisateur voit les diffs, commit, synchronise.
## Avant de modifier du code
- Demander le **numéro JIRA** si l'utilisateur ne l'a pas fourni (préfixe selon le projet, voir `projet/`).

View File

@ -14,6 +14,7 @@ alwaysApply: true
- Serveur = déploiement only (pas de `user.name` / commits serveur).
- Windows → Gitea → webhook → `deploy_dev.php` → `~/deploy/deploy_dev.sh`.
- **L'agent ne pousse jamais** et ne déclenche jamais `deploy_dev.php`. L'utilisateur synchronise.
## Subtilités / état à surveiller

View File

@ -1,14 +1,16 @@
---
description: Radar mail — proto CRM v4 (IDEE-4 / IDEE-5)
description: Radar mail — proto CRM v4 (IDEE-4 / MSOP)
alwaysApply: true
---
# Radar mail (CRM)
Épique **MSOP-1**. Idée : **IDEE-4**. Proto écran : **MSOP-2**.
Épique **MSOP-1**. Idée : **IDEE-4**. Proto écran : **MSOP-2**. Analyse IA : **MSOP-3**. Moteur Gmail : **MSOP-4**.
- UI : CI4 / Raven `/v4/radar` — pas SmartAdmin.
- Périmètre : tout `@ms1timing.com` moins `radar_exceptions` (v0 : `confirmation@`). Pas de palier `info@`.
- Moteur (plus tard) : Gmail Push + Gemini. Lécran lit `radar_threads`.
- Cible long terme : tout `@ms1timing.com` moins `radar_exceptions`.
- **MSOP-4 v0 (en cours)** : allowlist **une** mailbox — `leith.s@ms1timing.com` — avant domaine entier.
- Moteur : Gmail (réutiliser SA + DWD comme Calendar) + plus tard Gemini. Lécran lit `radar_threads`.
- Un fil = N skills. Vérité = CRM (on ninvente pas un client).
- Pas dans MS1 Inscription.
- Agent : **pas de commit/push** sauf demande explicite.

View File

@ -0,0 +1,7 @@
-- MSOP-4 — note (pas obligatoire)
-- Allowlist v0 = Config\Radar::$mailboxAllowlist (leith.s@ms1timing.com).
-- Pas de nouvelle table : lecture Gmail → radar_threads existantes.
-- Prérequis Google Admin (Domain-Wide Delegation) pour le SA
-- crm-calendar-service@….iam.gserviceaccount.com :
-- ajouter le scope https://www.googleapis.com/auth/gmail.readonly
-- (Calendar seul ne suffit pas).

View File

@ -0,0 +1,47 @@
<?php
namespace App\Commands;
use App\Libraries\Radar\RadarSyncService;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
/**
* MSOP-4 — php spark radar:sync
*/
class RadarSync extends BaseCommand
{
protected $group = 'Radar';
protected $name = 'radar:sync';
protected $description = 'Lit les mailboxes allowlist Gmail et remplit radar_threads (v0).';
public function run(array $params)
{
CLI::write('Radar sync MSOP-4…', 'yellow');
$result = (new RadarSyncService())->sync();
if (! empty($result['error']) && empty($result['mailboxes'])) {
CLI::error($result['error']);
return;
}
foreach ($result['mailboxes'] as $row) {
if (! empty($row['ok'])) {
CLI::write(sprintf(
'OK %s — fetched=%d insert=%d update=%d',
$row['mailbox'],
$row['fetched'] ?? 0,
$row['inserted'] ?? 0,
$row['updated'] ?? 0
), 'green');
} else {
CLI::error(($row['mailbox'] ?? '?') . ' — ' . ($row['error'] ?? 'erreur'));
}
}
if (! $result['ok']) {
CLI::write('Astuce DWD : ajouter le scope gmail.readonly pour le SA dans Google Admin.', 'yellow');
}
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* MSOP-4 — Radar Gmail v0 (allowlist une mailbox).
*/
class Radar extends BaseConfig
{
/**
* Seules ces boîtes sont lues en v0. Domaine entier = story suivante.
*
* @var list<string>
*/
public array $mailboxAllowlist = [
'leith.s@ms1timing.com',
];
/** Fenêtre de lecture initiale (jours). */
public int $lookbackDays = 14;
/** Max messages par boîte et run (garde-fou). */
public int $maxMessagesPerMailbox = 40;
/**
* Clé SA (même fichier que Calendar CRM).
* Relatif à ROOTPATH (dossier v4_ci4/).
*/
public string $serviceAccountJson = '../application/credentials/service_account.json';
public string $gmailScope = 'https://www.googleapis.com/auth/gmail.readonly';
}

View File

@ -10,6 +10,7 @@ $routes->get('/', 'Home::index');
// IDEE-5 — proto Radar (écran CRM v4)
$routes->get('radar', 'Radar::index');
$routes->get('radar/feed', 'Radar::feed');
$routes->get('radar/sync', 'Radar::sync'); // MSOP-4 — déclenche sync allowlist (auth requise)
$routes->get('inventaire/redirect/(:segment)', 'Inventaire::redirect/$1');
$routes->get('inventaire/test/(:segment)', 'Inventaire::test/$1');

View File

@ -2,10 +2,13 @@
namespace App\Controllers;
use App\Libraries\Radar\RadarSyncService;
use App\Models\RadarModel;
use Config\Radar as RadarConfig;
/**
* IDEE-5 — proto écran Radar (CI4 / Raven).
* IDEE-5 / MSOP-2 — proto écran Radar (CI4 / Raven).
* MSOP-4 — sync Gmail allowlist.
*/
class Radar extends BaseController
{
@ -13,19 +16,22 @@ class Radar extends BaseController
{
$model = new RadarModel();
$ready = $model->tablesExist();
$cfg = config(RadarConfig::class);
return view('radar/index', [
'ready' => $ready,
'exceptions' => $ready ? $model->listExceptions() : [],
'threads' => $ready ? $model->listThreads() : [],
'feedUrl' => site_url('v4/radar/feed'),
'ready' => $ready,
'exceptions' => $ready ? $model->listExceptions() : [],
'threads' => $ready ? $model->listThreads() : [],
'feedUrl' => site_url('v4/radar/feed'),
'syncUrl' => site_url('v4/radar/sync'),
'allowlist' => $cfg->mailboxAllowlist,
]);
}
public function feed()
{
$model = new RadarModel();
if (!$model->tablesExist()) {
if (! $model->tablesExist()) {
return $this->response->setJSON([
'ok' => false,
'threads' => [],
@ -38,4 +44,36 @@ class Radar extends BaseController
'threads' => $model->listThreads(),
]);
}
/**
* Sync Gmail → radar_* (mailbox allowlist uniquement).
*/
public function sync()
{
$result = (new RadarSyncService())->sync();
if ($this->request->isAJAX() || $this->request->getGet('json') !== null) {
return $this->response->setJSON($result);
}
$msg = $result['ok'] ? 'Sync OK' : 'Sync en erreur';
if (! empty($result['error'])) {
$msg .= ' — ' . $result['error'];
}
foreach ($result['mailboxes'] as $row) {
if (! empty($row['ok'])) {
$msg .= sprintf(
' | %s: +%d / ~%d (lus %d)',
$row['mailbox'],
$row['inserted'] ?? 0,
$row['updated'] ?? 0,
$row['fetched'] ?? 0
);
} else {
$msg .= ' | ' . ($row['mailbox'] ?? '?') . ': ' . ($row['error'] ?? 'fail');
}
}
return redirect()->to(site_url('v4/radar'))->with('radar_sync_msg', $msg);
}
}

View File

@ -0,0 +1,140 @@
<?php
namespace App\Libraries\Radar;
/**
* Lecture Gmail (readonly) pour une mailbox impersonnée.
*/
class GmailReader
{
public function __construct(
private GoogleWorkspaceToken $tokenClient,
private string $scopes,
) {
}
/**
* @return list<array{
* gmail_thread_id:string,
* mailbox:string,
* subject:string,
* from_email:string,
* from_name:string,
* snippet:string,
* gmail_url:string,
* received_at:?string
* }>
*/
public function listRecentThreads(string $mailbox, int $lookbackDays, int $maxMessages): array
{
$access = $this->tokenClient->getAccessToken($mailbox, $this->scopes);
$q = 'newer_than:' . max(1, $lookbackDays) . 'd';
$url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages?'
. http_build_query([
'q' => $q,
'maxResults' => $maxMessages,
]);
[$code, $body] = $this->tokenClient->http(
'GET',
$url,
['Authorization: Bearer ' . $access]
);
if ($code !== 200) {
throw new \RuntimeException("Gmail messages.list ({$code}): {$body}");
}
$data = json_decode($body, true) ?: [];
$ids = $data['messages'] ?? [];
if ($ids === []) {
return [];
}
$byThread = [];
foreach ($ids as $m) {
$msgId = $m['id'] ?? null;
if (! $msgId) {
continue;
}
$detail = $this->getMessageMeta($access, $msgId);
if ($detail === null) {
continue;
}
$tid = $detail['gmail_thread_id'];
// Garder le message le plus récent du fil dans ce batch
if (! isset($byThread[$tid]) || ($detail['internal_ts'] ?? 0) > ($byThread[$tid]['internal_ts'] ?? 0)) {
$detail['mailbox'] = $mailbox;
$byThread[$tid] = $detail;
}
}
$out = array_values($byThread);
usort($out, static fn ($a, $b) => ($b['internal_ts'] ?? 0) <=> ($a['internal_ts'] ?? 0));
foreach ($out as &$row) {
unset($row['internal_ts']);
}
unset($row);
return $out;
}
/**
* @return ?array<string, mixed>
*/
private function getMessageMeta(string $access, string $messageId): ?array
{
$url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/' . rawurlencode($messageId)
. '?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date';
[$code, $body] = $this->tokenClient->http(
'GET',
$url,
['Authorization: Bearer ' . $access]
);
if ($code !== 200) {
return null;
}
$msg = json_decode($body, true);
if (! is_array($msg)) {
return null;
}
$headers = [];
foreach ($msg['payload']['headers'] ?? [] as $h) {
$headers[strtolower($h['name'] ?? '')] = $h['value'] ?? '';
}
$from = $this->parseFrom($headers['from'] ?? '');
$ts = isset($msg['internalDate']) ? (int) floor(((int) $msg['internalDate']) / 1000) : 0;
return [
'gmail_thread_id' => (string) ($msg['threadId'] ?? $messageId),
'subject' => (string) ($headers['subject'] ?? '(sans sujet)'),
'from_email' => $from['email'],
'from_name' => $from['name'],
'snippet' => (string) ($msg['snippet'] ?? ''),
'gmail_url' => 'https://mail.google.com/mail/u/0/#all/' . rawurlencode((string) ($msg['threadId'] ?? $messageId)),
'received_at' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : null,
'internal_ts' => $ts,
];
}
/**
* @return array{email:string,name:string}
*/
private function parseFrom(string $from): array
{
if (preg_match('/^(.*)<([^>]+)>$/', trim($from), $m)) {
return [
'name' => trim($m[1], " \t\"'"),
'email' => strtolower(trim($m[2])),
];
}
$email = strtolower(trim($from));
return ['email' => $email, 'name' => ''];
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace App\Libraries\Radar;
/**
* JWT + access token Google Workspace (Domain-Wide Delegation).
* Même pattern que application/libraries/GCalService.php (MSOP-4).
*/
class GoogleWorkspaceToken
{
private string $jsonPath;
private string $tokenUri = 'https://oauth2.googleapis.com/token';
public function __construct(string $jsonPath)
{
$this->jsonPath = $jsonPath;
if (! is_file($this->jsonPath)) {
throw new \RuntimeException('Service account JSON introuvable: ' . $this->jsonPath);
}
}
public function getAccessToken(string $impersonateEmail, string $scopes): string
{
$creds = json_decode((string) file_get_contents($this->jsonPath), true);
if (! is_array($creds) || empty($creds['client_email']) || empty($creds['private_key'])) {
throw new \RuntimeException('service_account.json invalide');
}
$now = time();
$header = ['alg' => 'RS256', 'typ' => 'JWT'];
$claim = [
'iss' => $creds['client_email'],
'scope' => $scopes,
'aud' => $this->tokenUri,
'exp' => $now + 3600,
'iat' => $now,
'sub' => $impersonateEmail,
];
$unsigned = $this->b64url(json_encode($header)) . '.' . $this->b64url(json_encode($claim));
$signature = '';
if (! openssl_sign($unsigned, $signature, $creds['private_key'], OPENSSL_ALGO_SHA256)) {
throw new \RuntimeException('openssl_sign failed');
}
$jwt = $unsigned . '.' . $this->b64url($signature);
[$code, $body] = $this->http(
'POST',
$this->tokenUri,
['Content-Type: application/x-www-form-urlencoded'],
http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
])
);
if ($code !== 200) {
throw new \RuntimeException("Token Google error ({$code}): {$body}");
}
$tok = json_decode($body, true);
if (empty($tok['access_token'])) {
throw new \RuntimeException('Pas daccess_token dans la réponse Google');
}
return $tok['access_token'];
}
/**
* @param list<string> $headers
* @return array{0:int,1:string}
*/
public function http(string $method, string $url, array $headers = [], ?string $body = null): array
{
$ch = curl_init($url);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => $headers,
];
if ($body !== null) {
$opts[CURLOPT_POSTFIELDS] = $body;
}
curl_setopt_array($ch, $opts);
$res = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($res === false) {
$err = curl_error($ch);
curl_close($ch);
throw new \RuntimeException('cURL error: ' . $err);
}
curl_close($ch);
return [$code, $res];
}
private function b64url(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
}

View File

@ -0,0 +1,126 @@
<?php
namespace App\Libraries\Radar;
use App\Models\RadarModel;
use Config\Radar as RadarConfig;
/**
* MSOP-4 — sync Gmail allowlist → radar_threads / radar_thread_skills.
*/
class RadarSyncService
{
public function __construct(
private ?RadarConfig $config = null,
private ?RadarModel $model = null,
) {
$this->config ??= config(RadarConfig::class);
$this->model ??= new RadarModel();
}
/**
* @return array{ok:bool, mailboxes:list<array<string,mixed>>, error?:string}
*/
public function sync(): array
{
if (! $this->model->tablesExist()) {
return [
'ok' => false,
'mailboxes' => [],
'error' => 'Tables radar_* absentes — exécuter sql/IDEE-4-radar-proto.sql',
];
}
$jsonPath = realpath(ROOTPATH . $this->config->serviceAccountJson)
?: (ROOTPATH . $this->config->serviceAccountJson);
try {
$tokenClient = new GoogleWorkspaceToken($jsonPath);
$reader = new GmailReader($tokenClient, $this->config->gmailScope);
} catch (\Throwable $e) {
return ['ok' => false, 'mailboxes' => [], 'error' => $e->getMessage()];
}
$results = [];
foreach ($this->config->mailboxAllowlist as $mailbox) {
$mailbox = strtolower(trim($mailbox));
if ($mailbox === '') {
continue;
}
try {
$threads = $reader->listRecentThreads(
$mailbox,
$this->config->lookbackDays,
$this->config->maxMessagesPerMailbox
);
$inserted = 0;
$updated = 0;
foreach ($threads as $t) {
$skills = $this->guessSkills($t['subject'] . ' ' . $t['snippet']);
$status = $skills === [] ? 'a_classer' : 'a_classer';
$n = $this->model->upsertThread([
'gmail_thread_id' => $t['gmail_thread_id'],
'mailbox' => $mailbox,
'subject' => mb_substr($t['subject'], 0, 255),
'from_email' => mb_substr($t['from_email'], 0, 190),
'from_name' => mb_substr($t['from_name'], 0, 190),
'gmail_url' => mb_substr($t['gmail_url'], 0, 500),
'summary' => mb_substr($t['snippet'], 0, 500),
'status' => $status,
'confidence' => $skills === [] ? 20 : 45,
'is_example' => 0,
'received_at' => $t['received_at'],
'proposed_client_no' => '',
], $skills);
if ($n === 'insert') {
$inserted++;
} else {
$updated++;
}
}
$results[] = [
'mailbox' => $mailbox,
'ok' => true,
'fetched' => count($threads),
'inserted' => $inserted,
'updated' => $updated,
];
} catch (\Throwable $e) {
$results[] = [
'mailbox' => $mailbox,
'ok' => false,
'error' => $e->getMessage(),
];
}
}
$anyOk = false;
foreach ($results as $r) {
if (! empty($r['ok'])) {
$anyOk = true;
break;
}
}
return ['ok' => $anyOk || $results === [], 'mailboxes' => $results];
}
/**
* Heuristique v0 — pas encore Gemini (MSOP-3).
*
* @return list<string>
*/
private function guessSkills(string $text): array
{
$t = mb_strtolower($text);
$skills = [];
if (preg_match('/dossard|bib\b|imprimeur|s[eé]quence|logo\s*doss/', $t)) {
$skills[] = 'dossards';
}
if (preg_match('/inscription|site\s*web|formulaire|chronotrack|ouverture\s+site/', $t)) {
$skills[] = 'site';
}
return $skills;
}
}

View File

@ -69,4 +69,74 @@ class RadarModel
return $rows;
}
/**
* Insert ou met à jour un fil (dédup gmail_thread_id). Retourne insert|update.
*
* @param array<string, mixed> $data
* @param list<string> $skills
*/
public function upsertThread(array $data, array $skills = []): string
{
$db = $this->db();
$existing = $db->table('radar_threads')
->where('gmail_thread_id', $data['gmail_thread_id'])
->get()
->getRowArray();
if ($existing) {
$db->table('radar_threads')
->where('id', $existing['id'])
->update([
'mailbox' => $data['mailbox'],
'subject' => $data['subject'],
'from_email' => $data['from_email'],
'from_name' => $data['from_name'],
'gmail_url' => $data['gmail_url'],
'summary' => $data['summary'],
'status' => $data['status'],
'confidence' => $data['confidence'],
'received_at' => $data['received_at'],
'is_example' => 0,
]);
$threadId = (int) $existing['id'];
$op = 'update';
} else {
$db->table('radar_threads')->insert([
'gmail_thread_id' => $data['gmail_thread_id'],
'mailbox' => $data['mailbox'],
'subject' => $data['subject'],
'from_email' => $data['from_email'],
'from_name' => $data['from_name'],
'gmail_url' => $data['gmail_url'],
'summary' => $data['summary'],
'status' => $data['status'],
'confidence' => $data['confidence'],
'is_example' => (int) ($data['is_example'] ?? 0),
'received_at' => $data['received_at'],
'proposed_client_no' => $data['proposed_client_no'] ?? '',
]);
$threadId = (int) $db->insertID();
$op = 'insert';
}
foreach ($skills as $skill) {
$skill = strtolower(trim($skill));
if ($skill === '') {
continue;
}
$exists = $db->table('radar_thread_skills')
->where('thread_id', $threadId)
->where('skill', $skill)
->countAllResults();
if ($exists === 0) {
$db->table('radar_thread_skills')->insert([
'thread_id' => $threadId,
'skill' => $skill,
]);
}
}
return $op;
}
}

View File

@ -10,6 +10,9 @@ $ready = $ready ?? false;
$exceptions = $exceptions ?? [];
$threads = $threads ?? [];
$feedUrl = $feedUrl ?? site_url('v4/radar/feed');
$syncUrl = $syncUrl ?? site_url('v4/radar/sync');
$allowlist = $allowlist ?? [];
$syncMsg = session()->getFlashdata('radar_sync_msg');
$statusLabel = static function (string $status): string {
return match ($status) {
@ -39,25 +42,41 @@ $statusClass = static function (string $status): string {
<p class="text-xs font-medium uppercase tracking-wide text-zinc-500">CRM v4 · IDEE-5</p>
<h1 class="text-2xl font-semibold text-zinc-900 dark:text-white">Radar mail</h1>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Tout <span class="font-medium">@ms1timing.com</span> moins les exceptions.
Un fil peut porter plusieurs sujets. Le CRM est la vérité — on ninvente pas un client.
MSOP-4 v0 : lecture allowlist
<?php if ($allowlist !== []): ?>
(<span class="font-mono text-xs"><?= esc(implode(', ', $allowlist)) ?></span>)
<?php endif; ?>
— domaine entier plus tard. Le CRM est la vérité.
</p>
</div>
<div class="text-right">
<p class="text-xs text-zinc-500">Fil mis à jour</p>
<p id="radar-clock" class="text-sm font-medium text-zinc-800 dark:text-zinc-200">—</p>
<div class="flex flex-col items-end gap-2">
<?php if ($ready): ?>
<a href="<?= esc($syncUrl) ?>"
class="inline-flex items-center rounded-lg bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-500">
Sync Gmail (v0)
</a>
<?php endif; ?>
<div class="text-right">
<p class="text-xs text-zinc-500">Fil mis à jour</p>
<p id="radar-clock" class="text-sm font-medium text-zinc-800 dark:text-zinc-200">—</p>
</div>
</div>
</div>
<?php if ($syncMsg): ?>
<div class="rounded-xl border border-indigo-200 bg-indigo-50 px-4 py-3 text-sm text-indigo-950 dark:border-indigo-800 dark:bg-indigo-950/40 dark:text-indigo-100">
<?= esc($syncMsg) ?>
</div>
<?php endif; ?>
<?php if (!$ready): ?>
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100">
Tables absentes. Exécuter <code class="font-mono">sql/IDEE-4-radar-proto.sql</code> sur la BD CRM de <strong>dev</strong>, puis recharger.
</div>
<?php else: ?>
<div class="rounded-xl border border-zinc-200 bg-white px-4 py-3 text-sm text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300">
Moteur Gmail Push + Gemini : pas encore branché. Cet écran lit la table
<code class="font-mono">radar_threads</code> (deux exemples proto pour valider la saveur).
Périmètre cible : domaine entier, pas une seule boîte.
Sync = Gmail readonly sur la mailbox allowlist (SA + DWD). Analyse IA (MSOP-3) pas encore.
Si erreur token : ajouter le scope <code class="font-mono">gmail.readonly</code> au SA dans Google Admin.
</div>
<?php endif; ?>