$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);
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],
];
}