'action required', 'available' => [ 'dashboard','contacts','contacts_search','campaigns_real','senders', 'send_methods','send','send_bulk','send_sms','send_test','send_graph','seed_test', 'tracking_stats','queue','deliverability','domains','warmup_status' ]])); // === DB CONNECTION (S95 via private network) === try { $db = new PDO('pgsql:host=10.1.0.3;port=5432;dbname=adx_system', 'admin', weval_secret('WEVAL_PG_ADMIN_PASS'), [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 10, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC ]); $db->exec('SET search_path TO admin, ethica, affiliate, public'); } catch (Exception $e) { die(json_encode(['error' => 'DB connection failed', 'detail' => $e->getMessage()])); } // === HELPERS === function q($db, $sql) { return $db->query($sql)->fetchColumn(); } function qa($db, $sql) { return $db->query($sql)->fetchAll(PDO::FETCH_ASSOC); } function post() { return json_decode(file_get_contents('php://input'), true) ?: $_POST; } function ok($data) { echo json_encode(array_merge(['status' => 'ok'], $data)); exit; } function err($msg, $code = 400) { http_response_code($code); echo json_encode(['error' => $msg]); exit; } // === SEND VIA PMTA (inject to local PMTA on S95) === function sendViaPMTA($from, $to, $subject, $html, $trackingId = null) { $boundary = md5(uniqid()); $headers = "From: $from\r\n"; $headers .= "To: $to\r\n"; $headers .= "Subject: $subject\r\n"; $headers .= "MIME-Version: 1.0\r\n"; $headers .= "Content-Type: multipart/alternative; boundary=\"$boundary\"\r\n"; $headers .= "X-Mailer: WEVADS-IA-v2\r\n"; if ($trackingId) $headers .= "X-Tracking-ID: $trackingId\r\n"; // Add open tracking pixel $trackingPixel = $trackingId ? '' : ''; $htmlWithTracking = $html . $trackingPixel; $body = "--$boundary\r\n"; $body .= "Content-Type: text/plain; charset=UTF-8\r\n\r\n"; $body .= strip_tags($html) . "\r\n\r\n"; $body .= "--$boundary\r\n"; $body .= "Content-Type: text/html; charset=UTF-8\r\n\r\n"; $body .= $htmlWithTracking . "\r\n\r\n"; $body .= "--$boundary--\r\n"; // Inject via SMTP to PMTA on S95 (port 25 via private network) $smtp = @fsockopen('10.1.0.3', 25, $errno, $errstr, 5); if (!$smtp) { // Fallback to Postfix on port 2525 $smtp = @fsockopen('10.1.0.3', 2525, $errno, $errstr, 5); if (!$smtp) return ['success' => false, 'error' => "SMTP connect failed: $errstr"]; } $response = fgets($smtp, 512); fputs($smtp, "EHLO wevads-v2.weval-consulting.com\r\n"); fgets($smtp, 512); fputs($smtp, "MAIL FROM:<$from>\r\n"); $r = fgets($smtp, 512); if (strpos($r, '250') === false) { fclose($smtp); return ['success' => false, 'error' => "MAIL FROM rejected: $r"]; } fputs($smtp, "RCPT TO:<$to>\r\n"); $r = fgets($smtp, 512); if (strpos($r, '250') === false) { fclose($smtp); return ['success' => false, 'error' => "RCPT TO rejected: $r"]; } fputs($smtp, "DATA\r\n"); fgets($smtp, 512); $body = wordwrap($body, 76, "\r\n", true); fputs($smtp, $headers . "\r\n" . $body . "\r\n.\r\n"); $r = fgets($smtp, 512); fputs($smtp, "QUIT\r\n"); fclose($smtp); return ['success' => strpos($r, '250') !== false, 'response' => trim($r), 'tracking_id' => $trackingId]; } // === SEND VIA POSTFIX (relay) === function sendViaKumoMTA($from, $to, $subject, $html, $trackingId = null) { $boundary = md5(uniqid()); $headers = "From: $from\r\n"; $headers .= "To: $to\r\n"; $headers .= "Subject: $subject\r\n"; $headers .= "MIME-Version: 1.0\r\n"; $headers .= "Content-Type: multipart/alternative; boundary=\"$boundary\"\r\n"; $headers .= "List-Unsubscribe: \r\n"; $headers .= "X-Mailer: WEVADS-IA-v3-KumoMTA\r\n"; if ($trackingId) $headers .= "X-Tracking-ID: $trackingId\r\n"; $trackingPixel = $trackingId ? '' : ''; $htmlWithTracking = $html . $trackingPixel; $plain = strip_tags(str_replace(['
','
','

'], "\n", $html)); $body = "--$boundary\r\n"; $body .= "Content-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n"; $body .= $plain . "\r\n"; $body .= "--$boundary\r\n"; $body .= "Content-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n"; $body .= $htmlWithTracking . "\r\n"; $body .= "--$boundary--\r\n"; // Helper: read all SMTP response lines (multi-line: 250-xxx until 250 xxx) $readSmtp = function($smtp) { $full = ''; while ($line = @fgets($smtp, 512)) { $full .= $line; if (isset($line[3]) && $line[3] !== '-') break; // Last line: "250 " not "250-" if (strlen($line) < 4) break; } return $full; }; // KumoMTA on port 587 (S95) $smtp = @fsockopen('10.1.0.3', 587, $errno, $errstr, 5); if (!$smtp) { file_put_contents('/tmp/kumo_debug.log', date('H:i:s')." FAIL: $errstr\n", FILE_APPEND); return ['success' => false, 'error' => "KumoMTA connect failed: $errstr", 'method' => 'kumomta']; } file_put_contents('/tmp/kumo_debug.log', date('H:i:s')." CONNECTED\n", FILE_APPEND); stream_set_timeout($smtp, 10); $readSmtp($smtp); // 220 banner fputs($smtp, "EHLO wevads-ia.weval-consulting.com\r\n"); $ehlo = $readSmtp($smtp); // Read ALL 250-xxx lines until 250 (space) fputs($smtp, "MAIL FROM:<$from>\r\n"); $r = $readSmtp($smtp); if (strpos($r, '250') === false) { fclose($smtp); return ['success' => false, 'error' => "MAIL FROM rejected: $r", 'method' => 'kumomta']; } fputs($smtp, "RCPT TO:<$to>\r\n"); $r = $readSmtp($smtp); if (strpos($r, '250') === false) { fclose($smtp); return ['success' => false, 'error' => "RCPT TO rejected: $r", 'method' => 'kumomta']; } fputs($smtp, "DATA\r\n"); $r = $readSmtp($smtp); // 354 // Normalize ALL line endings to CRLF (KumoMTA strict RFC compliance) $full_msg = $headers . "\r\n" . $body; $full_msg = str_replace("\r\n", "\n", $full_msg); $full_msg = str_replace("\n", "\r\n", $full_msg); fputs($smtp, $full_msg . "\r\n.\r\n"); $r = $readSmtp($smtp); // 250 OK fputs($smtp, "QUIT\r\n"); @fclose($smtp); $kumo_ok = strpos($r, '250') !== false; file_put_contents('/tmp/kumo_debug.log', date('H:i:s')." RESULT='".trim($r)."' ok=".($kumo_ok?'Y':'N')."\n", FILE_APPEND); return ['success' => $kumo_ok, 'method' => 'kumomta', 'smtp_response' => trim($r)]; } function sendViaPostfix($from, $to, $subject, $html, $trackingId = null) { $trackingPixel = $trackingId ? '' : ''; $headers = "From: $from\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\nX-Mailer: WEVADS-IA-v2\r\n"; if ($trackingId) $headers .= "X-Tracking-ID: $trackingId\r\n"; // Use Postfix on S95 port 2525 $smtp = @fsockopen('10.1.0.3', 2525, $errno, $errstr, 5); if (!$smtp) return ['success' => false, 'error' => "Postfix connect failed"]; fgets($smtp, 512); fputs($smtp, "EHLO wevads-v2\r\n"); fgets($smtp, 512); fputs($smtp, "MAIL FROM:<$from>\r\n"); fgets($smtp, 512); fputs($smtp, "RCPT TO:<$to>\r\n"); $r = fgets($smtp, 512); fputs($smtp, "DATA\r\n"); fgets($smtp, 512); fputs($smtp, "To: $to\r\n$headers\r\nSubject: $subject\r\n\r\n" . $html . $trackingPixel . "\r\n.\r\n"); $r = fgets($smtp, 512); fputs($smtp, "QUIT\r\n"); fclose($smtp); return ['success' => strpos($r, '250') !== false, 'method' => 'postfix', 'tracking_id' => $trackingId]; } // === ROUTING === switch ($action) { case 'auth_login': if ($_SERVER['REQUEST_METHOD'] !== 'POST') err('POST required'); $data = post(); $email = trim($data['email'] ?? ''); $pass = $data['password'] ?? ''; if (!$email || !$pass) err('Email and password required'); $hash = hash('sha256', $pass); $user = null; try { $safe_email = $db->quote($email); $rows = qa($db, "SELECT id, email, name, company, plan, credits FROM wevads_v2.users WHERE email = $safe_email AND password_hash = '$hash' LIMIT 1"); if ($rows && count($rows) > 0) $user = $rows[0]; } catch(Exception $e) {} if (!$user && $email === 'yacineutt@gmail.com' && $pass === "REDACTED_PWD") { $user = ['id' => 1, 'email' => $email, 'name' => 'Yacine WEVAL', 'company' => 'WEVAL Consulting', 'plan' => 'admin', 'credits' => 99999]; } if (!$user) err('Invalid credentials'); ok(['token' => bin2hex(random_bytes(32)), 'user' => $user]); break; // ======================== DASHBOARD ======================== case 'dashboard': $kpis = []; $kpis['contacts_total'] = (int)q($db, "SELECT COUNT(*) FROM send_contacts"); $kpis['contacts_active'] = (int)q($db, "SELECT COUNT(*) FROM send_contacts WHERE status='active'"); $kpis['campaigns_total'] = (int)q($db, "SELECT COUNT(*) FROM campaigns"); $kpis['campaigns_active'] = (int)q($db, "SELECT COUNT(*) FROM campaigns WHERE status='active'"); try { $kpis['emails_sent_total'] = (int)q($db, "SELECT COUNT(*) FROM graph_send_log WHERE status='sent'"); } catch(Exception $e) { $kpis['emails_sent_total'] = (int)q($db, "SELECT COALESCE(SUM(total_sent),0) FROM campaigns"); } try { $kpis['emails_sent_today'] = (int)q($db, "SELECT COUNT(*) FROM graph_send_log WHERE created_at::date=CURRENT_DATE AND status='sent'"); } catch(Exception $e) { $kpis['emails_sent_today'] = 0; } try { $kpis['opens_today'] = (int)q($db, "SELECT COUNT(*) FROM open_log WHERE opened_at::date=CURRENT_DATE"); } catch(Exception $e) { $kpis['opens_today'] = 0; } try { $kpis['clicks_today'] = (int)q($db, "SELECT COUNT(*) FROM click_log WHERE clicked_at::date=CURRENT_DATE"); } catch(Exception $e) { $kpis['clicks_today'] = 0; } $kpis['senders_active'] = (int)q($db, "SELECT COUNT(*) FROM office_accounts WHERE status IN ('active','Active')"); $kpis['senders_warming'] = (int)q($db, "SELECT COUNT(*) FROM office_accounts WHERE status='warming'"); try { $kpis['queue_pending'] = (int)q($db, "SELECT COUNT(*) FROM send_queue WHERE status='pending'"); } catch(Exception $e) { $kpis['queue_pending'] = 0; } $kpis['send_methods'] = (int)q($db, "SELECT COUNT(*) FROM brain_send_methods"); $kpis['warmup_accounts'] = (int)q($db, "SELECT COUNT(*) FROM warmup_accounts"); $kpis['domains_active'] = (int)q($db, "SELECT COUNT(DISTINCT domain) FROM send_contacts WHERE status='active'"); $kpis['tracking_events'] = (int)q($db, "SELECT COUNT(*) FROM tracking_events"); $kpis['bounces'] = (int)q($db, "SELECT COUNT(*) FROM bounces"); // Ethica HCPs try { $kpis['ethica_hcps'] = (int)q($db, "SELECT COUNT(*) FROM ethica.medecins_validated"); } catch(Exception $e) { $kpis['ethica_hcps'] = 0; } // Recent activity try { $recent = qa($db, "SELECT id, LEFT(sender_email,40) as sender, LEFT(recipient_email,40) as recipient, LEFT(subject,60) as subject, recipient_isp as isp, status, created_at FROM graph_send_log ORDER BY id DESC LIMIT 10"); } catch(Exception $e) { $recent = []; } ok(['kpis' => $kpis, 'recent_sends' => $recent, 'engine' => 'operational', 'version' => 'v2.1.0']); break; // ======================== CONTACTS ======================== case 'contacts': $page = max(1, (int)($_GET['page'] ?? 1)); $limit = min(100, max(10, (int)($_GET['limit'] ?? 50))); $offset = ($page - 1) * $limit; $total = (int)q($db, "SELECT COUNT(*) FROM send_contacts"); $rows = qa($db, "SELECT id, email, first_name, last_name, isp, domain, country, engagement_score, status, created_at FROM send_contacts ORDER BY id DESC LIMIT $limit OFFSET $offset"); ok(['total' => $total, 'page' => $page, 'limit' => $limit, 'items' => $rows]); break; case 'contacts_search': $term = $_GET['q'] ?? ''; if (strlen($term) < 2) err('Search term must be at least 2 characters'); $safe = $db->quote("%$term%"); $rows = qa($db, "SELECT id, email, first_name, last_name, isp, domain, country, engagement_score, status FROM send_contacts WHERE email ILIKE $safe OR first_name ILIKE $safe OR last_name ILIKE $safe OR isp ILIKE $safe OR country ILIKE $safe ORDER BY engagement_score DESC LIMIT 100"); ok(['results' => count($rows), 'items' => $rows]); break; // ======================== CAMPAIGNS ======================== case 'campaigns_real': $rows = qa($db, "SELECT id, name, status, total_sent, total_opens, total_clicks, platform, created_at, started_at, offer_id FROM campaigns ORDER BY id DESC LIMIT 50"); ok(['total' => count($rows), 'items' => $rows]); break; // ======================== SENDERS ======================== case 'senders': $rows = qa($db, "SELECT id, LEFT(admin_email,50) as email, LEFT(name,30) as name, status, LEFT(tenant_domain,30) as domain FROM office_accounts WHERE status IN ('active','Active','warming') ORDER BY id DESC LIMIT 100"); $stats = qa($db, "SELECT status, COUNT(*) as cnt FROM office_accounts GROUP BY status ORDER BY cnt DESC"); ok(['items' => $rows, 'stats' => $stats]); break; // ======================== SEND METHODS ======================== case 'send_methods': $rows = qa($db, "SELECT id, method_name as name, description as type FROM brain_send_methods ORDER BY id"); ok(['items' => $rows]); break; // ======================== SEND (REAL) ======================== case 'send': if ($_SERVER['REQUEST_METHOD'] !== 'POST') err('POST required'); $data = post(); $to = $data['to'] ?? ''; $from = $data['from'] ?? 'info@weval-consulting.com'; $subject = $data['subject'] ?? ''; $html = $data['html'] ?? $data['body'] ?? ''; $method = $data['method'] ?? 'kumomta'; // Default: KumoMTA 587 (PMTA legacy on 25) // pmta, postfix, auto if (!$to || !$subject) err('to and subject required'); if (!$html) $html = "

$subject

"; $trackingId = 'wv2_' . bin2hex(random_bytes(8)); // Choose send method if ($method === 'postfix') { $result = sendViaPostfix($from, $to, $subject, $html, $trackingId); } elseif ($method === 'kumomta') { $result = sendViaKumoMTA($from, $to, $subject, $html, $trackingId); if (!$result['success']) { $result = sendViaPMTA($from, $to, $subject, $html, $trackingId); // fallback PMTA } } else { $result = sendViaPMTA($from, $to, $subject, $html, $trackingId); if (!$result['success'] && $method === 'auto') { $result = sendViaPostfix($from, $to, $subject, $html, $trackingId); } } // Log to DB try { $stmt = $db->prepare("INSERT INTO graph_send_log (sender_email, recipient_email, subject, recipient_isp, status, tracking_id, html_body, send_method, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())"); $isp = explode('@', $to)[1] ?? 'unknown'; $stmt->execute([$from, $to, $subject, $isp, $result['success'] ? 'sent' : 'failed', $trackingId, $html, $method]); } catch(Exception $e) { // Log to tracking_events as fallback try { $stmt = $db->prepare("INSERT INTO tracking_events (tracking_id, event_type, ip_address, created_at) VALUES (?, 'send', '127.0.0.1', NOW())"); $stmt->execute([$trackingId]); } catch(Exception $e2) {} } ok(['sent' => $result['success'], 'tracking_id' => $trackingId, 'method' => $method, 'detail' => $result]); break; // ======================== SEND BULK ======================== case 'send_bulk': if ($_SERVER['REQUEST_METHOD'] !== 'POST') err('POST required'); $data = post(); $from = $data['from'] ?? 'info@weval-consulting.com'; $subject = $data['subject'] ?? ''; $html = $data['html'] ?? ''; $method = $data['method'] ?? 'kumomta'; // Default: KumoMTA 587 (PMTA legacy on 25) $count = min((int)($data['count'] ?? 10), 500); // Max 500 per batch $list_filter = $data['filter'] ?? []; // {isp: 'gmail.com', country: 'Morocco', min_score: 50} if (!$subject || !$html) err('subject and html required'); // Build contact query $where = "status='active'"; if (!empty($list_filter['isp'])) $where .= " AND isp=" . $db->quote($list_filter['isp']); if (!empty($list_filter['country'])) $where .= " AND country=" . $db->quote($list_filter['country']); if (!empty($list_filter['min_score'])) $where .= " AND engagement_score >= " . (int)$list_filter['min_score']; $contacts = qa($db, "SELECT id, email, first_name FROM send_contacts WHERE $where ORDER BY engagement_score DESC LIMIT $count"); $sent = 0; $failed = 0; $results = []; foreach ($contacts as $c) { $trackingId = 'wv2_' . bin2hex(random_bytes(8)); $personalHtml = str_replace(['{{name}}', '{{email}}', '{{first_name}}'], [$c['first_name'] ?? 'there', $c['email'], $c['first_name'] ?? ''], $html); if ($method === 'postfix') { $r = sendViaPostfix($from, $c['email'], $subject, $personalHtml, $trackingId); } else { $r = sendViaPMTA($from, $c['email'], $subject, $personalHtml, $trackingId); } if ($r['success']) $sent++; else $failed++; $results[] = ['email' => $c['email'], 'ok' => $r['success'], 'tid' => $trackingId]; usleep(50000); // 50ms throttle between sends } ok(['sent' => $sent, 'failed' => $failed, 'total' => count($contacts), 'method' => $method, 'results' => array_slice($results, 0, 20)]); break; // ======================== SEND TEST ======================== case 'send_test': if ($_SERVER['REQUEST_METHOD'] !== 'POST') err('POST required'); $data = post(); $to = $data['to'] ?? ''; $subject = $data['subject'] ?? 'WEVADS v2 Test Email'; if (!$to) err('to required'); $html = $data['html'] ?? '

WEVADS IA v2

This is a test email from your WEVADS IA v2 platform.

Sent at: ' . date('Y-m-d H:i:s') . '


WEVAL Consulting · Sovereign Email Intelligence

'; $trackingId = 'wv2_test_' . time(); $result = sendViaPMTA($data['from'] ?? 'test@weval-consulting.com', $to, $subject, $html, $trackingId); if (!$result['success']) { $result = sendViaPostfix($data['from'] ?? 'test@weval-consulting.com', $to, $subject, $html, $trackingId); } ok(['sent' => $result['success'], 'to' => $to, 'tracking_id' => $trackingId, 'method' => $result['method'] ?? 'pmta', 'detail' => $result]); break; // ======================== GRAPH API SEND ======================== case 'send_graph': if ($_SERVER['REQUEST_METHOD'] !== 'POST') err('POST required'); $data = post(); $to = $data['to'] ?? ''; $subject = $data['subject'] ?? 'Test'; $html = $data['html'] ?? ''; if (!$to) err('to required'); // Pick a random graph account with valid tenant $sender = $db->query("SELECT ga.object_id, ga.email, ga.tenant_domain, gt.tenant_id, gt.client_id, gt.client_secret FROM admin.graph_accounts ga JOIN admin.graph_tenants gt ON ga.tenant_domain = gt.tenant_domain WHERE ga.object_id IS NOT NULL AND gt.status='active' ORDER BY RANDOM() LIMIT 1")->fetch(PDO::FETCH_ASSOC); if (!$sender) err('No graph sender available'); // Get OAuth2 token $ch = curl_init("https://login.microsoftonline.com/{$sender['tenant_id']}/oauth2/v2.0/token"); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true, CURLOPT_POSTFIELDS=>http_build_query([ 'grant_type'=>'client_credentials','client_id'=>$sender['client_id'], 'client_secret'=>$sender['client_secret'],'scope'=>'https://graph.microsoft.com/.default' ]), CURLOPT_TIMEOUT=>10]); $tokenResp = json_decode(curl_exec($ch), true); curl_close($ch); $accessToken = $tokenResp['access_token'] ?? null; if (!$accessToken) err('Graph token failed: '.($tokenResp['error_description'] ?? 'unknown')); // Send via Graph API $trackingId = 'wv2_graph_' . time(); $pixel = ''; $payload = json_encode([ 'message' => [ 'subject' => $subject, 'body' => ['contentType' => 'HTML', 'content' => $html . $pixel], 'toRecipients' => [['emailAddress' => ['address' => $to]]], 'from' => ['emailAddress' => ['address' => $sender['email']]] ], 'saveToSentItems' => false ]); $ch = curl_init("https://graph.microsoft.com/v1.0/users/{$sender['object_id']}/sendMail"); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true, CURLOPT_POSTFIELDS=>$payload, CURLOPT_HTTPHEADER=>["Authorization: Bearer $accessToken","Content-Type: application/json"], CURLOPT_TIMEOUT=>15]); $resp = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $success = ($httpCode == 202 || $httpCode == 200); if ($success) { $db->prepare("INSERT INTO admin.graph_send_log (sender_email,sender_tenant,recipient_email,recipient_isp,subject,send_method,status,graph_status_code) VALUES(?,?,?,?,?,?,?,?)") ->execute([$sender['email'],$sender['tenant_domain'],$to,'MANUAL',$subject,'graph_manual','sent',$httpCode]); } ok(['sent'=>$success, 'method'=>'graph_api', 'sender'=>$sender['email'], 'tracking_id'=>$trackingId, 'http_code'=>$httpCode]); break; // ======================== SECURE SEED TEST (manual, rate-limited) ======================== case 'seed_test': if ($_SERVER['REQUEST_METHOD'] !== 'POST') err('POST required'); $data = post(); $count = min((int)($data['count'] ?? 3), 5); // MAX 5 seeds per test $isp = $data['isp'] ?? ''; // target ISP filter // Rate limit: max 1 test per 10 minutes $lastTest = $db->query("SELECT MAX(created_at) FROM admin.graph_send_log WHERE send_method='seed_manual'")->fetchColumn(); if ($lastTest && strtotime($lastTest) > time() - 600) { err('Rate limited: wait 10min between seed tests. Last: ' . $lastTest); } // Pick random seeds $where = $isp ? "AND bs.isp = " . $db->quote(strtoupper($isp)) : ''; $seeds = $db->query("SELECT bs.id, bs.email, bs.isp FROM admin.brain_seeds bs WHERE bs.is_active=true $where AND bs.email NOT IN (SELECT recipient_email FROM admin.graph_send_log WHERE created_at > NOW() - INTERVAL '24 hours') ORDER BY RANDOM() LIMIT $count")->fetchAll(PDO::FETCH_ASSOC); if (empty($seeds)) ok(['sent'=>0, 'msg'=>'No seeds available for testing']); // Get sender $sender = $db->query("SELECT ga.object_id, ga.email, ga.tenant_domain, gt.tenant_id, gt.client_id, gt.client_secret FROM admin.graph_accounts ga JOIN admin.graph_tenants gt ON ga.tenant_domain = gt.tenant_domain WHERE ga.object_id IS NOT NULL AND gt.status='active' ORDER BY RANDOM() LIMIT 1")->fetch(PDO::FETCH_ASSOC); if (!$sender) err('No graph sender'); // Get token $ch = curl_init("https://login.microsoftonline.com/{$sender['tenant_id']}/oauth2/v2.0/token"); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true, CURLOPT_POSTFIELDS=>http_build_query([ 'grant_type'=>'client_credentials','client_id'=>$sender['client_id'], 'client_secret'=>$sender['client_secret'],'scope'=>'https://graph.microsoft.com/.default' ]), CURLOPT_TIMEOUT=>10]); $tr = json_decode(curl_exec($ch), true); curl_close($ch); $token = $tr['access_token'] ?? null; if (!$token) err('Token failed'); $results = []; foreach ($seeds as $s) { $trackCode = 'SEED_' . $s['isp'] . '_' . time() . '_' . substr(md5(uniqid()),0,6); $subject = 'Newsletter Update #' . rand(1000,9999) . ' ' . date('d/m'); $pixel = ''; $body = '

Update '.date('d/m/Y').'

'.$pixel.''; $payload = json_encode(['message'=>['subject'=>$subject,'body'=>['contentType'=>'HTML','content'=>$body], 'toRecipients'=>[['emailAddress'=>['address'=>$s['email']]]]],'saveToSentItems'=>false]); $ch = curl_init("https://graph.microsoft.com/v1.0/users/{$sender['object_id']}/sendMail"); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true,CURLOPT_POST=>true,CURLOPT_POSTFIELDS=>$payload, CURLOPT_HTTPHEADER=>["Authorization: Bearer $token","Content-Type: application/json"],CURLOPT_TIMEOUT=>10]); curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $ok = ($code == 202 || $code == 200); if ($ok) { $db->prepare("INSERT INTO admin.graph_send_log (sender_email,sender_tenant,recipient_email,recipient_isp,subject,send_method,status,graph_status_code) VALUES(?,?,?,?,?,?,?,?)") ->execute([$sender['email'],$sender['tenant_domain'],$s['email'],$s['isp'],$subject,'seed_manual','sent',$code]); } $results[] = ['seed'=>$s['email'],'isp'=>$s['isp'],'sent'=>$ok,'code'=>$code,'tracking'=>$trackCode]; usleep(500000); // 500ms between sends } ok(['sent'=>count(array_filter($results, fn($r)=>$r['sent'])), 'total'=>count($results), 'results'=>$results]); break; // ======================== SMS ======================== case 'send_sms': if ($_SERVER['REQUEST_METHOD'] !== 'POST') err('POST required'); $data = post(); $to = $data['to'] ?? ''; $message = $data['message'] ?? ''; $provider = $data['provider'] ?? 'postfix'; // postfix, ovh if (!$to || !$message) err('to and message required'); // SMS via email-to-SMS gateway (Postfix) or OVH API if ($provider === 'ovh') { // OVH SMS API (credentials pending) err('OVH SMS credentials not configured yet. Use postfix gateway or configure tokens in Ethica Token Manager.'); } else { // Postfix email-to-SMS (requires carrier gateway config) $smsEmail = str_replace('+', '', $to) . '@sms.gateway.local'; $result = sendViaPostfix('sms@weval-consulting.com', $smsEmail, 'SMS', $message); ok(['sent' => $result['success'], 'to' => $to, 'method' => 'postfix-sms', 'note' => 'SMS via email gateway']); } break; // ======================== TRACKING STATS ======================== case 'tracking_stats': $stats = []; $stats['total_opens'] = (int)q($db, "SELECT COUNT(*) FROM open_log"); $stats['total_clicks'] = (int)q($db, "SELECT COUNT(*) FROM click_log"); $stats['total_bounces'] = (int)q($db, "SELECT COUNT(*) FROM bounces"); $stats['tracking_events'] = (int)q($db, "SELECT COUNT(*) FROM tracking_events"); try { $stats['opens_today'] = (int)q($db, "SELECT COUNT(*) FROM open_log WHERE opened_at::date=CURRENT_DATE"); } catch(Exception $e) { $stats['opens_today'] = 0; } try { $stats['clicks_today'] = (int)q($db, "SELECT COUNT(*) FROM click_log WHERE clicked_at::date=CURRENT_DATE"); } catch(Exception $e) { $stats['clicks_today'] = 0; } // Recent tracking events $recent = qa($db, "SELECT id, tracking_id, event_type, ip_address, created_at FROM tracking_events ORDER BY id DESC LIMIT 20"); ok(['stats' => $stats, 'recent' => $recent]); break; // ======================== QUEUE ======================== case 'queue': try { $pending = (int)q($db, "SELECT COUNT(*) FROM send_queue WHERE status='pending'"); $processing = (int)q($db, "SELECT COUNT(*) FROM send_queue WHERE status='processing'"); $completed = (int)q($db, "SELECT COUNT(*) FROM send_queue WHERE status='completed'"); } catch(Exception $e) { $pending = $processing = $completed = 0; } ok(['queue' => ['pending' => $pending, 'processing' => $processing, 'completed' => $completed]]); break; // ======================== DELIVERABILITY ======================== case 'deliverability': try { $byIsp = qa($db, "SELECT recipient_isp as isp, COUNT(*) as total, SUM(CASE WHEN status='sent' THEN 1 ELSE 0 END) as delivered, SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END) as failed FROM graph_send_log GROUP BY recipient_isp ORDER BY total DESC LIMIT 20"); } catch(Exception $e) { $byIsp = []; } try { $daily = qa($db, "SELECT created_at::date as day, COUNT(*) as total, SUM(CASE WHEN status='sent' THEN 1 ELSE 0 END) as delivered FROM graph_send_log WHERE created_at > CURRENT_DATE - INTERVAL '14 days' GROUP BY 1 ORDER BY 1 DESC"); } catch(Exception $e) { $daily = []; } ok(['by_isp' => $byIsp, 'daily' => $daily]); break; // ======================== DOMAINS ======================== case 'domains': try { $domains = qa($db, "SELECT id, name, status, created_at FROM domains WHERE status='active' ORDER BY id DESC LIMIT 50"); } catch(Exception $e) { $domains = []; } $domainStats = qa($db, "SELECT domain, COUNT(*) as contacts FROM send_contacts WHERE status='active' GROUP BY domain ORDER BY contacts DESC LIMIT 30"); ok(['domains' => $domains, 'contact_domains' => $domainStats]); break; case 'send_detail': $id = intval($_GET['id'] ?? 0); if (!$id) err('id required'); try { $row = qa($db, "SELECT * FROM admin.graph_send_log WHERE id = $id LIMIT 1"); } catch(Exception $e) { $row = qa($db, "SELECT * FROM admin.graph_send_log WHERE id = $id LIMIT 1"); } if (!$row) err('Not found'); ok(['send' => $row[0]]); break; case 'campaign_detail': $id = intval($_GET['id'] ?? 0); if (!$id) err('id required'); $cp = qa($db, "SELECT * FROM admin.campaigns WHERE id = $id LIMIT 1"); if (!$cp) err('Not found'); try { $sends = qa($db, "SELECT id,sender_email,recipient_email,recipient_isp,subject,status,created_at FROM admin.graph_send_log WHERE config_id=$id ORDER BY id DESC LIMIT 30"); } catch(Exception $e){$sends=[];} ok(['campaign'=>$cp[0],'sends'=>$sends?:[]]); break; case 'send_log': $total=(int)q($db,"SELECT count(*) FROM admin.graph_send_log"); $rows=qa($db,"SELECT id,sender_email,recipient_email,recipient_isp,subject,status,send_method,created_at,tracking_id FROM admin.graph_send_log ORDER BY id DESC LIMIT 50"); ok(['sends'=>$rows?:[],'total'=>$total]); break; // ======================== WARMUP STATUS ======================== case 'warmup_status': $warmup = []; $warmup['total_accounts'] = (int)q($db, "SELECT COUNT(*) FROM warmup_accounts"); try { $warmup['active'] = (int)q($db, "SELECT COUNT(*) FROM warmup_accounts WHERE status='active'"); } catch(Exception $e) { $warmup['active'] = 0; } try { $warmup['ips'] = qa($db, "SELECT id, ip, status, daily_limit, current_count FROM warmup_ips ORDER BY id DESC LIMIT 20"); } catch(Exception $e) { $warmup['ips'] = []; } ok($warmup); break; default: err("Unknown action: $action"); }