Files
fitme3070.pt/config/functions_queue.php
T
andreandClaude Opus 5 0e034232e9 Survive a stale platform-tools instead of 500ing the whole site
Deploying the local-SMTP commit took fitme3070.pt down:

    PHP Warning: require_once(.../shared/platform-tools/compat_secrets.php):
    Failed to open stream: No such file or directory
    in .../config/functions_secrets.php on line 20

config/functions_secrets.php is loaded on every request, so a hard require_once
on a submodule file is a site-wide fatal — public pages included — whenever
shared/platform-tools lags the code that needs it. It lagged: the server is
still on 603ff84, which predates compat_secrets.php, because "Atualizar
submódulo" was itself broken (fixed in 3626080, which the site had to be up to
deploy). A missing file should not be able to close that loop.

Three requires hardened, each to the failure the caller can actually report:

  - config/functions_secrets.php: falls back to plaintext passthrough helpers
    and logs loudly. secrets_available() then returns false, which is exactly
    what the Comunicações page already renders a warning banner for — so
    nothing silently pretends to be encrypted while the site keeps serving.

  - config/functions_queue.php (_flush_email): throws instead of fataling. A
    fatal killed the request outright, so flush_message()'s catch never ran and
    the row sat in 'sending' forever with no explanation. It now lands in
    last_error where the Monitor page shows it.

  - views/sysadmin/comms.php: renders "Submódulo desactualizado" naming the
    missing files, rather than a blank 500 on the page you would open to
    diagnose mail in the first place.

Verified by loading config/functions_secrets.php with no shared/ directory
present: the bootstrap completes, the warning is logged once, secrets_available()
is false, and setting_secret()/setting_or_env() still return usable values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:13:44 +01:00

566 lines
23 KiB
PHP

<?php
// Local outbound message queue (email + SMS + WhatsApp) backed by `message_queue`.
//
// Delivery happens in this app — there is no central relay. Each channel has a
// small driver:
// email → PHPMailer over SMTP (or PHP mail() when SMTP_HOST is empty)
// sms → Twilio Messages API, plain SMS sender
// whatsapp → Twilio Messages API, `whatsapp:` sender
//
// Standard pattern for transactional sends (immediate delivery expected):
// $id = enqueue_email([...]);
// return flush_message($id);
//
// Standard pattern for broadcasts (deferred is acceptable):
// foreach ($recipients as $u) {
// enqueue_email([...]);
// }
// // cron.php drains them within 5 minutes; no inline flush needed.
//
// Every row keeps its status, attempt count, last error and timestamps, so the
// Monitor page (views/sysadmin/messages.php) can show what was sent and retry
// what wasn't.
const MESSAGE_QUEUE_VALID_STATUSES = ['pending', 'sending', 'sent', 'failed', 'cancelled'];
const MESSAGE_QUEUE_VALID_CHANNELS = ['email', 'sms', 'whatsapp'];
// ─── Enqueue ─────────────────────────────────────────────────────────────────
function enqueue_email(array $data): int {
return _enqueue_message('email', $data);
}
function enqueue_sms(array $data): int {
return _enqueue_message('sms', $data);
}
function enqueue_whatsapp(array $data): int {
return _enqueue_message('whatsapp', $data);
}
function _enqueue_message(string $channel, array $data): int {
global $pdo;
$stmt = $pdo->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.
// A bare require_once would be a FATAL when shared/platform-tools lags the
// pinned commit (this install is deployed by file upload, so it does lag).
// A fatal here kills the whole request; flush_message()'s catch never runs
// and the row stays in 'sending' forever with no explanation. Throwing puts
// the reason in last_error, where the Monitor page shows it.
$_ptRoot = __DIR__ . '/../shared/platform-tools/';
foreach (['functions_mail.php', 'functions_settings.php'] as $_ptFile) {
if (!is_file($_ptRoot . $_ptFile)) {
throw new RuntimeException(
"shared/platform-tools/{$_ptFile} em falta — o submódulo está desactualizado. "
. 'Actualize-o em Sistema → Actualizações.'
);
}
require_once $_ptRoot . $_ptFile;
}
// 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<string> 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 '<span class="badge text-bg-' . $colour . '">' . htmlspecialchars(message_status_label($status)) . '</span>';
}
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,
};
}