$first(['SMTP_HOST']), 'port' => (int) ($get('SMTP_PORT', '587') ?: 587), 'username' => $first(['SMTP_USERNAME', 'SMTP_USER']), 'password' => $first(['SMTP_PASSWORD', 'SMTP_PASS']), 'secure' => $first(['SMTP_SECURE'], 'tls'), 'from' => $first(['EMAIL_FROM', 'SMTP_FROM_EMAIL', 'SMTP_FROM']), 'from_name' => $first(['EMAIL_FROM_NAME', 'NOTIFY_FROM_NAME']), // Safety nets. Only consulted when APP_ENV is not 'prod', so a // misconfigured production install cannot silently swallow mail. 'env' => $get('APP_ENV', 'prod'), 'dev_print' => filter_var($get('EMAIL_DEV_PRINT', ''), FILTER_VALIDATE_BOOLEAN), 'dev_redirect' => $get('EMAIL_DEV_REDIRECT'), ]; if ($override) { foreach ($override as $k => $v) { // Only accept keys we know, and never let an override blank a value // the host already resolved -- a partial array must not wipe the rest. if (array_key_exists($k, $cfg) && $v !== null && $v !== '') $cfg[$k] = $v; } } return $cfg; } function platform_mail_is_configured(?array $override = null): bool { return platform_mail_config($override)['host'] !== ''; } /** * Loads PHPMailer, preferring composer's autoloader and falling back to the * hand-vendored copy some installs carry at vendor/PHPMailer/src/. * Both layouts exist across the fleet, so neither can be assumed. */ function platform_mail_available(string $repo_root = ''): bool { if (class_exists(\PHPMailer\PHPMailer\PHPMailer::class)) return true; $root = $repo_root !== '' ? rtrim($repo_root, '/') : dirname(__DIR__, 2); foreach ([$root . '/vendor/autoload.php'] as $auto) { if (is_file($auto)) { require_once $auto; if (class_exists(\PHPMailer\PHPMailer\PHPMailer::class)) return true; } } $base = $root . '/vendor/PHPMailer/src/'; if (is_file($base . 'PHPMailer.php')) { require_once $base . 'Exception.php'; require_once $base . 'PHPMailer.php'; require_once $base . 'SMTP.php'; return class_exists(\PHPMailer\PHPMailer\PHPMailer::class); } return false; } /** A configured PHPMailer, or null when the class cannot be loaded. */ function platform_mail_make(string $repo_root = '', ?array $override = null) { if (!platform_mail_available($repo_root)) return null; $c = platform_mail_config($override); $mail = new \PHPMailer\PHPMailer\PHPMailer(true); // true = throw on failure $mail->CharSet = 'UTF-8'; if ($c['host'] !== '') { $mail->isSMTP(); $mail->Host = $c['host']; $mail->Port = $c['port']; $mail->SMTPAuth = ($c['username'] !== ''); $mail->Username = $c['username']; $mail->Password = $c['password']; // '' and 'none' both mean plaintext; assigning either to SMTPSecure // makes PHPMailer attempt a protocol it cannot negotiate. if ($c['secure'] !== '' && strtolower($c['secure']) !== 'none') { $mail->SMTPSecure = $c['secure']; } } else { $mail->isMail(); // hand off to the local MTA if one exists } if ($c['from'] !== '') { $mail->setFrom($c['from'], $c['from_name']); } return $mail; } /** * Sends one message. * * $msg: to, to_name, subject, body (html), alt_body, reply_to, reply_to_name, * attachments [[path, name], …] * * Returns ['ok' => bool, 'skipped' => bool, 'error' => ?string]. * 'skipped' is true when a dev safety net swallowed the message on purpose -- * distinct from a failure, so a queue worker can mark it sent rather than * retrying forever against a dev box. */ function platform_mail_send(array $msg, string $repo_root = '', ?array $override = null, ?PDO $pdo = null): array { $c = platform_mail_config($override); $to = trim((string) ($msg['to'] ?? '')); if ($to === '') { return ['ok' => false, 'skipped' => false, 'error' => 'Destinatário vazio.']; } $isProd = ($c['env'] === 'prod'); // Log instead of sending. Checked BEFORE loading PHPMailer so a host with // no mailer installed can still run end to end in development. if (!$isProd && $c['dev_print']) { error_log(sprintf('[mail:dev_print] to=%s subject=%s', $to, (string) ($msg['subject'] ?? ''))); return ['ok' => true, 'skipped' => true, 'error' => null]; } // Tecto de envio. Com $pdo, e antes de se gastar tempo a montar o PHPMailer. // Uma mensagem recusada aqui devolve rate_limited: quem chama de uma fila // deve deixá-la pendente e voltar a tentar, não marcá-la como falhada. if ($pdo instanceof PDO) { require_once __DIR__ . '/functions_mail_rate.php'; $capped = platform_mail_rate_blocked($pdo); if ($capped !== null) { error_log('[mail-rate] envio adiado: ' . $capped); require_once __DIR__ . '/functions_security_events.php'; platform_security_event($pdo, 'mail_rate_limited', $capped, ''); return ['ok' => false, 'skipped' => false, 'rate_limited' => true, 'error' => $capped]; } } $mail = platform_mail_make($repo_root, $override); if ($mail === null) { return ['ok' => false, 'skipped' => false, 'error' => 'PHPMailer indisponível — corre "composer install", ou activa EMAIL_DEV_PRINT fora de produção.']; } try { // Redirect every recipient to one inbox outside production, so testing // against a copy of the live database cannot mail real clients. if (!$isProd && $c['dev_redirect'] !== '') { $mail->addAddress($c['dev_redirect']); $mail->Subject = '[DEV] ' . (string) ($msg['subject'] ?? ''); // Banner naming the real recipient. Without it a redirected mailbox // fills with messages that all look addressed to itself, and it is // impossible to tell who each one was actually for. $orig = $to . (($msg['to_name'] ?? '') !== '' ? ' <' . $msg['to_name'] . '>' : ''); $banner = '
' . '[DEV redirect] Destinatário original: ' . htmlspecialchars($orig, ENT_QUOTES, 'UTF-8') . '
'; $msg['body'] = $banner . (string) ($msg['body'] ?? ''); } else { $mail->addAddress($to, (string) ($msg['to_name'] ?? '')); $mail->Subject = (string) ($msg['subject'] ?? ''); } if (!empty($msg['reply_to'])) { $mail->addReplyTo((string) $msg['reply_to'], (string) ($msg['reply_to_name'] ?? '')); } $html = (string) ($msg['body'] ?? ''); $text = (string) ($msg['alt_body'] ?? ''); if ($html !== '') { $mail->isHTML(true); $mail->Body = $html; $mail->AltBody = $text !== '' ? $text : trim(strip_tags($html)); } else { $mail->isHTML(false); $mail->Body = $text; } foreach ((array) ($msg['attachments'] ?? []) as $att) { if (is_array($att) && !empty($att[0]) && is_file($att[0])) { $mail->addAttachment($att[0], (string) ($att[1] ?? '')); } } $mail->send(); if ($pdo instanceof PDO) { platform_mail_rate_record($pdo); } return ['ok' => true, 'skipped' => false, 'error' => null]; } catch (\Throwable $e) { // ErrorInfo carries PHPMailer's own diagnosis, which is usually more // specific than the exception message. $err = $mail->ErrorInfo !== '' ? $mail->ErrorInfo : $e->getMessage(); return ['ok' => false, 'skipped' => false, 'error' => $err]; } } /** * Opens an SMTP connection and closes it without sending. Used by the * "testar ligação" control so a misconfiguration surfaces before a queue * fills up with failures. */ function platform_mail_test(string $repo_root = '', ?array $override = null): array { if (!platform_mail_is_configured($override)) { return ['ok' => false, 'error' => 'SMTP_HOST não está configurado.', 'log' => '']; } $mail = platform_mail_make($repo_root, $override); if ($mail === null) { return ['ok' => false, 'error' => 'PHPMailer indisponível — corre "composer install".', 'log' => '']; } $log = ''; $mail->SMTPDebug = 2; // PHPMailer redacts the credential lines itself $mail->Debugoutput = function ($str) use (&$log) { $log .= $str; }; try { $ok = $mail->smtpConnect(); $mail->smtpClose(); return ['ok' => (bool) $ok, 'error' => $ok ? '' : 'A ligação foi recusada.', 'log' => $log]; } catch (\Throwable $e) { return ['ok' => false, 'error' => $mail->ErrorInfo !== '' ? $mail->ErrorInfo : $e->getMessage(), 'log' => $log]; } } /** * O que aconteceria a uma mensagem enviada agora mesmo. * * Devolve [classe-bootstrap, título, explicação]. Vale a pena ser explícito: * "configurado" e "entrega" não são a mesma coisa, e uma rede de segurança de * desenvolvimento a engolir email em silêncio é, vista de fora, indistinguível * de sucesso. * * Vivia dentro do smtp_status.php. Saiu para aqui quando o comms.php passou a * mostrar o mesmo veredicto -- duas cópias desta lista de casos divergiriam, e * a que ficasse para trás mentiria a alguém a meio de uma avaria. */ function platform_mail_diagnosis(string $repo_root = '', ?array $override = null, ?PDO $pdo = null): array { // Com $pdo, diagnostica-se a configuração que realmente envia (a da base), // e não a que estiver no ambiente. if ($override === null && $pdo !== null && function_exists('platform_smtp_config')) { $override = platform_smtp_config($pdo); } $cfg = platform_mail_config($override); $isProd = ($cfg['env'] === 'prod'); if (!$isProd && $cfg['dev_print']) { return ['warning', 'Não entrega', 'EMAIL_DEV_PRINT está activo e APP_ENV não é prod — as mensagens são registadas no log, não enviadas.']; } if ($cfg['host'] === '') { return ['danger', 'Não configurado', 'O servidor SMTP está vazio. Sem servidor configurado o envio falha (não há MTA local neste contentor).']; } if (!platform_mail_available($repo_root)) { return ['danger', 'PHPMailer em falta', 'A classe não foi encontrada — corre "composer install".']; } if (!$isProd && $cfg['dev_redirect'] !== '') { return ['warning', 'Redireccionado', 'Todas as mensagens vão para ' . $cfg['dev_redirect'] . ' em vez do destinatário real (APP_ENV não é prod).']; } return ['success', 'Operacional', 'As mensagens são entregues ao servidor SMTP configurado.']; } /** * Os valores em vigor, prontos para uma tabela: [etiqueta, valor, ok?]. * * A password nunca sai daqui, nem mascarada com o comprimento real -- isso * ainda diz quantos caracteres tem. */ function platform_mail_config_rows(?array $override = null, ?PDO $pdo = null): array { if ($override === null && $pdo !== null && function_exists('platform_smtp_config')) { $override = platform_smtp_config($pdo); } $cfg = platform_mail_config($override); $isProd = ($cfg['env'] === 'prod'); return [ ['Servidor', $cfg['host'] !== '' ? $cfg['host'] : '— vazio —', $cfg['host'] !== ''], ['Porta', (string) $cfg['port'], true], ['Cifra', $cfg['secure'] !== '' ? $cfg['secure'] : 'nenhuma', true], ['Utilizador', $cfg['username'] !== '' ? $cfg['username'] : '— vazio —', true], ['Palavra-passe', $cfg['password'] !== '' ? '•••••••• (definida)' : '— vazia —', $cfg['password'] !== ''], ['Remetente', $cfg['from'] !== '' ? $cfg['from'] : '— vazio —', $cfg['from'] !== ''], ['Nome do remetente', $cfg['from_name'] !== '' ? $cfg['from_name'] : '— vazio —', true], ['APP_ENV', $cfg['env'], true], ['EMAIL_DEV_PRINT', $cfg['dev_print'] ? 'true' : 'false', !$cfg['dev_print'] || $isProd], ['EMAIL_DEV_REDIRECT', $cfg['dev_redirect'] !== '' ? $cfg['dev_redirect'] : '— vazio —', true], ]; }