prepare(" INSERT INTO message_queue (channel, to_user_id, to_address, to_name, reply_to, subject, body, alt_body, context_type, context_id, scheduled_for, max_attempts, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $channel, $data['to_user_id'] ?? null, $data['to_address'], $data['to_name'] ?? null, // Sem isto, responder ao aviso de contacto respondia ao remetente da // casa e nao a quem escreveu -- o flush_message() ja o passava ao // mailer, era o INSERT que nao o guardava. $data['reply_to'] ?? null, $data['subject'] ?? null, $data['body'], $data['alt_body'] ?? null, $data['context_type'] ?? null, $data['context_id'] ?? null, $data['scheduled_for'] ?? null, $data['max_attempts'] ?? 3, $data['created_by'] ?? ($_SESSION['user']['id'] ?? null), ]); return (int) $pdo->lastInsertId(); } // ─── Flush a single message ────────────────────────────────────────────────── // Atomically claims a pending/failed row, sends it, updates status. // Returns true on send success, false otherwise. function flush_message(int $id): bool { global $pdo; // Atomic claim — only one worker proceeds. Guards against the cron run and // an inline flush racing for the same row. $claim = $pdo->prepare(" UPDATE message_queue SET status = 'sending', attempts = attempts + 1 WHERE id = ? AND status IN ('pending', 'failed') "); $claim->execute([$id]); if ($claim->rowCount() === 0) return false; $row = db_get_message($pdo, $id); if (!$row) return false; try { match ($row['channel']) { 'email' => _flush_email($row), 'sms' => _flush_twilio($row, 'sms'), 'whatsapp' => _flush_twilio($row, 'whatsapp'), default => throw new RuntimeException("Unknown channel: {$row['channel']}"), }; $pdo->prepare("UPDATE message_queue SET status='sent', sent_at=NOW(), last_error=NULL, next_attempt_at=NULL WHERE id=?")->execute([$id]); return true; } catch (\Throwable $e) { // `attempts` was already incremented by the claim, so it reflects this // attempt. Once it reaches max_attempts the row parks in 'failed' and // only a manual retry (Monitor page) revives it. $maxed = ((int) $row['attempts']) >= ((int) $row['max_attempts']); $newStatus = $maxed ? 'failed' : 'pending'; // Exponential backoff: 2^attempts minutes (2, 4, 8, ...) $nextAt = $maxed ? null : date('Y-m-d H:i:s', time() + (60 * (2 ** (int) $row['attempts']))); $pdo->prepare("UPDATE message_queue SET status=?, last_error=?, next_attempt_at=? WHERE id=?") ->execute([$newStatus, mb_substr($e->getMessage(), 0, 2000), $nextAt, $id]); error_log("flush_message($id) failed: {$e->getMessage()}"); return false; } } // ─── Driver: email (PHPMailer / SMTP) ──────────────────────────────────────── function _flush_email(array $row): void { // Dev-mode debug print: skip SMTP entirely, write the email to a log file // and a short summary to PHP's error_log. Kept here rather than in the // shared transport because it writes this app's own log format. if (APP_ENV !== 'prod' && EMAIL_DEV_PRINT) { _dev_print_message($row, 'EMAIL'); return; } // Transport is shared with the rest of the fleet (fotografia, hub). It applies // the EMAIL_DEV_REDIRECT safety net itself, including the [DEV] subject // prefix and the banner naming the original recipient, so that logic no // longer lives here. require_once __DIR__ . '/../shared/platform-tools/functions_mail.php'; require_once __DIR__ . '/../shared/platform-tools/functions_settings.php'; // Settings come from system_settings first, .env second -- the fleet-wide // standard. This install stores them there via setting_or_env(); this // just reads them through the shared accessor instead of the local // constants, so all three platforms resolve SMTP identically. $r = platform_mail_send([ 'to' => $row['to_address'], 'to_name' => $row['to_name'] ?? '', // A fila não deve depender de uma constante que não é dela. O // EMAIL_SUBJECT_PREFIX é definido no functions.php, e o index.php não o // inclui de propósito -- o resultado era «Undefined constant» apanhado // pelo flush_message() e gravado em last_error, com a página a dizer só // "Ocorreu um erro" e nada no log do Apache. 'subject' => (defined('EMAIL_SUBJECT_PREFIX') ? EMAIL_SUBJECT_PREFIX : '') . ($row['subject'] ?? ''), 'body' => $row['body'], 'alt_body' => $row['alt_body'] ?: strip_tags($row['body']), 'reply_to' => $row['reply_to'] ?? '', // O 4º argumento liga o tecto de envio (functions_mail_rate): 20/minuto e // 200/hora, partilhados com o resto da quinta. Sem o $pdo o tecto não se // aplica -- o módulo estava aqui e não estava a ser usado. ], dirname(__DIR__), platform_smtp_config($GLOBALS['pdo']), $GLOBALS['pdo'] ?? null); // flush_message() treats a thrown exception as "retry later" and records // last_error, so a failure must throw rather than return quietly. if (empty($r['ok'])) { throw new RuntimeException($r['error'] ?? 'Envio de email falhou.'); } } // ─── Driver: SMS + WhatsApp (Twilio Messages API) ──────────────────────────── // Both channels hit the same endpoint; only the sender differs (WhatsApp needs // the `whatsapp:` prefix on both From and To). Throws on any non-2xx so // flush_message() records the reason and schedules a retry. function _flush_twilio(array $row, string $channel): void { if (APP_ENV !== 'prod' && SMS_DEV_PRINT) { _dev_print_message($row, strtoupper($channel)); return; } if (TWILIO_SID === '' || TWILIO_TOKEN === '') { throw new RuntimeException('Twilio não configurado (TWILIO_SID / TWILIO_TOKEN em falta no .env)'); } $from = $channel === 'whatsapp' ? TWILIO_WHATSAPP_FROM : TWILIO_SMS_FROM; if ($from === '') { $var = $channel === 'whatsapp' ? 'TWILIO_WHATSAPP_FROM' : 'TWILIO_SMS_FROM'; throw new RuntimeException("Remetente não configurado ({$var} em falta no .env)"); } $to = sms_normalize_number($row['to_address']); if ($to === null) { throw new RuntimeException("Número de telefone inválido: {$row['to_address']}"); } if ($channel === 'whatsapp') { $from = 'whatsapp:' . $from; $to = 'whatsapp:' . $to; } // SMS/WhatsApp are plain text. Bodies built from the HTML email templates // would arrive as raw markup, so strip tags defensively. $body = trim($row['body']); if ($body !== strip_tags($body)) { $body = trim(html_entity_decode(strip_tags($body), ENT_QUOTES | ENT_HTML5, 'UTF-8')); } if ($body === '') { throw new RuntimeException('Mensagem vazia'); } $url = 'https://api.twilio.com/2010-04-01/Accounts/' . rawurlencode(TWILIO_SID) . '/Messages.json'; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 20, CURLOPT_USERPWD => TWILIO_SID . ':' . TWILIO_TOKEN, CURLOPT_POSTFIELDS => http_build_query([ 'From' => $from, 'To' => $to, 'Body' => $body, ]), ]); $resp = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($resp === false) { throw new RuntimeException("Erro de ligação ao Twilio: {$err}"); } if ($code < 200 || $code >= 300) { // Twilio returns {"code":21211,"message":"Invalid 'To' Phone Number"}. // Surfacing that message is what makes the Monitor page actionable. $data = json_decode((string) $resp, true); $detail = is_array($data) ? trim(($data['message'] ?? '') . (isset($data['code']) ? " (Twilio {$data['code']})" : '')) : substr((string) $resp, 0, 300); throw new RuntimeException("Twilio HTTP {$code}: " . ($detail !== '' ? $detail : 'sem detalhe')); } } // Coerces a stored phone into E.164. Accepts "+351915435034", "351915435034", // "915435034" and anything with spaces/dashes. Returns null when it can't. function sms_normalize_number(string $raw): ?string { $raw = trim($raw); $hasPlus = str_starts_with($raw, '+'); $digits = preg_replace('/\D+/', '', $raw) ?? ''; if ($digits === '') return null; if ($hasPlus) { return strlen($digits) >= 8 ? '+' . $digits : null; } $cc = preg_replace('/\D+/', '', SMS_DEFAULT_COUNTRY_CODE) ?? ''; // Already carries the country code (e.g. 351915435034) — just add the plus. if ($cc !== '' && str_starts_with($digits, $cc) && strlen($digits) > strlen($cc)) { return '+' . $digits; } // National number — prefix the default country code. if ($cc !== '' && strlen($digits) >= 9) { return '+' . $cc . $digits; } return strlen($digits) >= 11 ? '+' . $digits : null; } // Inverse of sms_normalize_number(): the human-readable form used for the // printed contact on the public site. A national number under the default // country code drops the prefix and is grouped in threes ("+351928363069" -> // "928 363 069"); anything else keeps the international form, which is the // only unambiguous way to write it. function sms_format_display(string $e164): string { $cc = preg_replace('/\D+/', '', SMS_DEFAULT_COUNTRY_CODE) ?? ''; $digits = preg_replace('/\D+/', '', $e164) ?? ''; if ($cc !== '' && str_starts_with($digits, $cc)) { $national = substr($digits, strlen($cc)); if (strlen($national) === 9) { return trim(chunk_split($national, 3, ' ')); } } return $e164; } // Writes the full message to logs/message_dev.log and a summary line to PHP's // error_log. Only called under APP_ENV != prod + EMAIL_DEV_PRINT / SMS_DEV_PRINT. function _dev_print_message(array $row, string $label): void { $logDir = __DIR__ . '/../logs'; $logFile = $logDir . '/message_dev.log'; if (!is_dir($logDir)) @mkdir($logDir, 0775, true); $sep = str_repeat('─', 70) . "\n"; $entry = $sep . sprintf("[%s] %s (DEV PRINT — não enviado)\n", date('Y-m-d H:i:s'), $label) . sprintf("To: %s%s\n", $row['to_address'], $row['to_name'] ? " <{$row['to_name']}>" : '') . sprintf("Subject: %s\n", EMAIL_SUBJECT_PREFIX . ($row['subject'] ?? '')) . sprintf("Context: %s#%s\n", $row['context_type'] ?? '?', $row['context_id'] ?? '?') . $sep . ($row['body'] ?? '') . "\n\n"; @file_put_contents($logFile, $entry, FILE_APPEND); error_log(sprintf( '[%s-dev] %s → %s (registado em logs/message_dev.log)', strtolower($label), $row['subject'] ?? '(sem assunto)', $row['to_address'] )); } // ─── Drain the queue ───────────────────────────────────────────────────────── // Picks up to $limit due rows and processes them in turn. Returns counts. // Called by cron.php every 5 minutes; also safe to call inline after a bulk // enqueue. function process_queue(int $limit = 20): array { global $pdo; $limit = max(1, $limit); $rows = $pdo->query(" SELECT id FROM message_queue WHERE status = 'pending' AND deleted_at IS NULL AND (scheduled_for IS NULL OR scheduled_for <= NOW()) AND (next_attempt_at IS NULL OR next_attempt_at <= NOW()) ORDER BY scheduled_for ASC, id ASC LIMIT $limit ")->fetchAll(); $sent = 0; $failed = 0; foreach ($rows as $r) { if (flush_message((int) $r['id'])) $sent++; else $failed++; } return ['sent' => $sent, 'failed' => $failed, 'total' => count($rows)]; } // Rows stuck in 'sending' mean a worker died mid-send (fatal, timeout, deploy). // Nothing would ever pick them up again, so the cron releases them back to // 'pending' once they're older than $minutes. function requeue_stuck_messages(PDO $pdo, int $minutes = 15): int { $minutes = max(1, $minutes); return (int) $pdo->exec(" UPDATE message_queue SET status = 'pending', last_error = CONCAT('Retomado após bloqueio em \"sending\" (', COALESCE(last_error, ''), ')') WHERE status = 'sending' AND updated_at < NOW() - INTERVAL $minutes MINUTE "); } // ─── CRUD helpers (used by the Monitor admin page) ─────────────────────────── function db_get_message(PDO $pdo, int $id): array|false { $stmt = $pdo->prepare("SELECT * FROM message_queue WHERE id = ?"); $stmt->execute([$id]); return $stmt->fetch(); } /** * Fetch messages with rich filters. * * $filters keys: * - statuses array one or more valid statuses (OR) * - channel string 'email' | 'sms' | 'whatsapp' * - search string LIKE on to_address / to_name / subject * - days int|null limit to created_at within N days * - include_deleted bool default false (exclude soft-deleted) * - only_deleted bool default false (only soft-deleted) * - limit int default 200 */ function db_get_messages(PDO $pdo, array $filters = []): array { $statuses = $filters['statuses'] ?? []; $channel = $filters['channel'] ?? null; $search = trim((string) ($filters['search'] ?? '')); $days = isset($filters['days']) ? (int) $filters['days'] : null; $include_deleted = !empty($filters['include_deleted']); $only_deleted = !empty($filters['only_deleted']); $limit = max(1, (int) ($filters['limit'] ?? 200)); $where = []; $args = []; $statuses = array_values(array_intersect($statuses, MESSAGE_QUEUE_VALID_STATUSES)); if ($statuses) { $where[] = 'q.status IN (' . implode(',', array_fill(0, count($statuses), '?')) . ')'; foreach ($statuses as $s) $args[] = $s; } if ($channel && in_array($channel, MESSAGE_QUEUE_VALID_CHANNELS, true)) { $where[] = 'q.channel = ?'; $args[] = $channel; } if ($search !== '') { $where[] = '(q.to_address LIKE ? OR q.to_name LIKE ? OR q.subject LIKE ?)'; $like = '%' . $search . '%'; $args[] = $like; $args[] = $like; $args[] = $like; } if ($days !== null && $days > 0) { $where[] = "q.created_at >= NOW() - INTERVAL $days DAY"; } if ($only_deleted) { $where[] = 'q.deleted_at IS NOT NULL'; } elseif (!$include_deleted) { $where[] = 'q.deleted_at IS NULL'; } $sql = " SELECT q.*, u.name AS user_name FROM message_queue q LEFT JOIN users u ON q.to_user_id = u.id "; if ($where) $sql .= ' WHERE ' . implode(' AND ', $where); $sql .= " ORDER BY q.created_at DESC LIMIT $limit"; $stmt = $pdo->prepare($sql); $stmt->execute($args); return $stmt->fetchAll(); } /** * Status counts (excluding soft-deleted by default). * Optional $days limits the window. Adds a virtual 'deleted' key with the * count of soft-deleted rows. */ function db_count_messages_by_status(PDO $pdo, ?int $days = null): array { $where = ['deleted_at IS NULL']; if ($days !== null && $days > 0) { $where[] = "created_at >= NOW() - INTERVAL $days DAY"; } $whereSql = 'WHERE ' . implode(' AND ', $where); $counts = $pdo->query(" SELECT status, COUNT(*) AS n FROM message_queue $whereSql GROUP BY status ")->fetchAll(PDO::FETCH_KEY_PAIR); foreach (MESSAGE_QUEUE_VALID_STATUSES as $s) $counts[$s] = (int) ($counts[$s] ?? 0); $counts['deleted'] = (int) $pdo->query("SELECT COUNT(*) FROM message_queue WHERE deleted_at IS NOT NULL")->fetchColumn(); return $counts; } // Number of rows waiting for the next cron pass. Drives the navbar badge. function db_count_pending_messages(PDO $pdo): int { return (int) $pdo->query(" SELECT COUNT(*) FROM message_queue WHERE status IN ('pending','failed') AND deleted_at IS NULL ")->fetchColumn(); } function db_retry_message(PDO $pdo, int $id): void { // Resetting `attempts` is what makes a retry meaningful — otherwise a row // that already burned its 3 attempts would fail straight back to 'failed' // on the first error without any backoff. $pdo->prepare(" UPDATE message_queue SET status='pending', attempts=0, next_attempt_at=NULL, last_error=NULL, deleted_at=NULL WHERE id=? AND status IN ('failed','cancelled') ")->execute([$id]); } function db_cancel_message(PDO $pdo, int $id): void { $pdo->prepare(" UPDATE message_queue SET status='cancelled' WHERE id=? AND status IN ('pending','failed') ")->execute([$id]); } function db_soft_delete_message(PDO $pdo, int $id): void { $pdo->prepare(" UPDATE message_queue SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL AND status <> 'sending' ")->execute([$id]); } function db_restore_message(PDO $pdo, int $id): void { $pdo->prepare(" UPDATE message_queue SET deleted_at = NULL WHERE id = ? ")->execute([$id]); } /** Bulk soft-delete sent messages older than N days. Returns affected rows. */ function db_soft_delete_sent_older_than(PDO $pdo, int $days): int { $days = max(1, (int) $days); return (int) $pdo->exec(" UPDATE message_queue SET deleted_at = NOW() WHERE deleted_at IS NULL AND status = 'sent' AND sent_at < NOW() - INTERVAL $days DAY "); } /** Hard-delete rows soft-deleted more than N days ago. Called by cron.php. */ function db_purge_deleted_messages(PDO $pdo, int $days): int { $days = max(1, (int) $days); return (int) $pdo->exec(" DELETE FROM message_queue WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL $days DAY "); } // ─── Display helpers ───────────────────────────────────────────────────────── function message_status_label(string $status): string { return match ($status) { 'pending' => 'Pendente', 'sending' => 'A enviar', 'sent' => 'Enviado', 'failed' => 'Falhou', 'cancelled' => 'Cancelado', default => ucfirst($status), }; } function message_status_badge(string $status): string { $colour = match ($status) { 'pending' => 'secondary', 'sending' => 'info', 'sent' => 'success', 'failed' => 'danger', 'cancelled' => 'dark', default => 'secondary', }; return '' . htmlspecialchars(message_status_label($status)) . ''; } function message_channel_label(string $channel): string { return match ($channel) { 'email' => 'Email', 'sms' => 'SMS', 'whatsapp' => 'WhatsApp', default => ucfirst($channel), }; } function message_channel_icon(string $channel): string { return match ($channel) { 'email' => 'bi-envelope', 'sms' => 'bi-chat-left-text', 'whatsapp' => 'bi-whatsapp', default => 'bi-send', }; } function message_context_label(?string $type): string { return match ($type) { 'gallery_delivery' => 'Entrega de galeria', 'session_scheduled' => 'Sessão agendada', 'session_reminder' => 'Lembrete de sessão', 'session_status' => 'Notificação de sessão', 'print_job_status' => 'Notificação de encomenda', 'password_reset' => 'Reposição de palavra-passe', 'newsletter' => 'Newsletter', 'broadcast' => 'Campanha (template)', 'quote_pack' => 'Pack de orçamento', 'quote_document' => 'Orçamento', 'voucher' => 'Vale oferta', 'contract_request' => 'Pedido de assinatura', null, '' => '—', default => $type, }; }