Files
html/api/wevads-v2-engine.php
2026-04-12 22:57:03 +02:00

629 lines
33 KiB
PHP

<?php
require_once __DIR__ . '/_secrets.php';
/**
* WEVADS IA v2 — Operational Send Engine API
* Bridge: S204 → S95 PostgreSQL (adx_system) + PMTA + Postfix + SMS
* All methods: PMTA Direct, Postfix, O365, GSuite, SMS (OVH/Postfix)
*
* Endpoints:
* ?action=dashboard — Real KPIs from DB
* ?action=contacts — Real contacts from send_contacts (7.3M)
* ?action=contacts_search — Search contacts by email/name/ISP/country
* ?action=campaigns_real — Real campaigns from DB
* ?action=senders — Active sender accounts
* ?action=send_methods — Available send methods
* ?action=send — REAL SEND (POST) via PMTA/Postfix
* ?action=send_bulk — Bulk send to contact list
* ?action=send_sms — SMS send via Postfix/OVH
* ?action=send_test — Test send to single email
* ?action=tracking_stats — Real tracking data
* ?action=queue — Send queue status
* ?action=deliverability — ISP-level deliverability stats
* ?action=domains — Active sending domains
* ?action=warmup_status — IP warmup status
*/
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
$action = $_GET['action'] ?? '';
if (!$action) die(json_encode(['error' => '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 ? '<img src="https://weval-consulting.com/api/tracking-relay.php?t=' . $trackingId . '&e=open" width="1" height="1" style="display:none">' : '';
$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: <mailto:unsubscribe@weval-consulting.com>\r\n";
$headers .= "X-Mailer: WEVADS-IA-v3-KumoMTA\r\n";
if ($trackingId) $headers .= "X-Tracking-ID: $trackingId\r\n";
$trackingPixel = $trackingId ? '<img src="https://weval-consulting.com/api/tracking-relay.php?t=' . $trackingId . '&e=open" width="1" height="1" style="display:none">' : '';
$htmlWithTracking = $html . $trackingPixel;
$plain = strip_tags(str_replace(['<br>','<br/>','</p>'], "\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 ? '<img src="https://weval-consulting.com/api/tracking-relay.php?t=' . $trackingId . '&e=open" width="1" height="1" style="display:none">' : '';
$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 = "<p>$subject</p>";
$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'] ?? '<div style="font-family:sans-serif;max-width:600px;margin:0 auto;padding:20px">
<h1 style="color:#c9a84c">WEVADS IA v2</h1>
<p>This is a test email from your WEVADS IA v2 platform.</p>
<p style="color:#666">Sent at: ' . date('Y-m-d H:i:s') . '</p>
<hr style="border:1px solid #eee">
<p style="font-size:11px;color:#999">WEVAL Consulting · Sovereign Email Intelligence</p>
</div>';
$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 = '<img src="https://weval-consulting.com/api/tracking-relay.php?t='.$trackingId.'&e=open" width="1" height="1" style="display:none">';
$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 = '<img src="https://weval-consulting.com/api/tracking-relay.php?t='.$trackCode.'&e=open" width="1" height="1" style="display:none">';
$body = '<html><body style="font-family:Arial"><p>Update '.date('d/m/Y').'</p>'.$pixel.'</body></html>';
$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");
}