$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 = '