exec(" CREATE TABLE IF NOT EXISTS `mail_sends` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `sent_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY `idx_mail_sends_at` (`sent_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 "); } /** Tectos em vigor, da base de dados, do .env, ou os do módulo. */ function platform_mail_rate_limits(?PDO $pdo = null): array { $get = function (string $key, int $default) use ($pdo): int { if ($pdo instanceof PDO && function_exists('platform_settings_get')) { $v = trim((string) platform_settings_get($pdo, strtolower($key), '')); if ($v !== '' && ctype_digit($v)) { return (int) $v; } } if (function_exists('env')) { $v = trim((string) env($key, '')); if ($v !== '' && ctype_digit($v)) { return (int) $v; } } return $default; }; return [ // Generosos de propósito. Isto é uma rede contra disparos em cadeia, // não uma quota: um tecto apertado de mais transforma-se em avarias // silenciosas no dia em que a academia mandar mesmo 60 confirmações. 'per_minute' => max(1, $get('MAIL_RATE_PER_MINUTE', 20)), 'per_hour' => max(1, $get('MAIL_RATE_PER_HOUR', 200)), ]; } /** * Pode sair mais uma mensagem agora? * * Devolve null quando sim, ou o motivo quando não -- para quem chama poder * registá-lo em vez de falhar em silêncio. */ function platform_mail_rate_blocked(PDO $pdo): ?string { try { platform_mail_rate_ensure_table($pdo); $lim = platform_mail_rate_limits($pdo); $st = $pdo->query(" SELECT SUM(sent_at >= NOW() - INTERVAL 1 MINUTE) AS m, SUM(sent_at >= NOW() - INTERVAL 1 HOUR) AS h FROM mail_sends WHERE sent_at >= NOW() - INTERVAL 1 HOUR "); $r = $st->fetch(PDO::FETCH_ASSOC) ?: ['m' => 0, 'h' => 0]; if ((int) $r['m'] >= $lim['per_minute']) { return "tecto de {$lim['per_minute']} envios/minuto atingido"; } if ((int) $r['h'] >= $lim['per_hour']) { return "tecto de {$lim['per_hour']} envios/hora atingido"; } return null; } catch (Throwable $e) { // Falha fechado: sem forma de contar, não se dispara às cegas. error_log('[mail-rate] contagem indisponível, a travar por precaução: ' . $e->getMessage()); return 'contador de envios indisponível'; } } /** Regista um envio. Chamar DEPOIS de a mensagem sair, nunca antes. */ function platform_mail_rate_record(PDO $pdo): void { try { platform_mail_rate_ensure_table($pdo); $pdo->exec("INSERT INTO mail_sends () VALUES ()"); // Limpeza oportunista: a tabela só serve para as últimas 24h, e sem // isto cresceria para sempre. Uma vez em cada cem inserções chega. if (random_int(1, 100) === 1) { $pdo->exec("DELETE FROM mail_sends WHERE sent_at < NOW() - INTERVAL 1 DAY"); } } catch (Throwable $e) { error_log('[mail-rate] não registou o envio: ' . $e->getMessage()); } } /** Quantos saíram no último minuto e na última hora — para a página de estado. */ function platform_mail_rate_usage(PDO $pdo): array { $lim = platform_mail_rate_limits($pdo); try { platform_mail_rate_ensure_table($pdo); $r = $pdo->query(" SELECT SUM(sent_at >= NOW() - INTERVAL 1 MINUTE) AS m, SUM(sent_at >= NOW() - INTERVAL 1 HOUR) AS h FROM mail_sends WHERE sent_at >= NOW() - INTERVAL 1 HOUR ")->fetch(PDO::FETCH_ASSOC) ?: []; return [ 'minute' => (int) ($r['m'] ?? 0), 'hour' => (int) ($r['h'] ?? 0), 'per_minute' => $lim['per_minute'], 'per_hour' => $lim['per_hour'], ]; } catch (Throwable) { return ['minute' => 0, 'hour' => 0] + $lim; } }