shared/platform-tools was a git submodule. On this branch it is a plain
directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90
— the same commit fotografia.andmore.pt pins. Contents are byte-identical to
that tree, verified blob by blob.
The submodule caused every deployment failure this site has had:
- .gitmodules named no `branch`, so the update fell back to
refs/remotes/origin/HEAD — a ref a plain server-side clone never writes.
"Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD
revision in submodule path".
- Its URL was http://, which the origin answers with 523.
- This install is deployed by uploading files, and a submodule is invisible
to that. The tree moved forward while shared/platform-tools stayed on the
old pin, config/functions_secrets.php died on a missing compat_secrets.php,
and the whole site — public pages included — went down.
Each of those was separately fixable. Together they mean a second repository
nobody can see from the deployment path, gating whether the first one boots. A
plain directory deploys like every other file and cannot fall out of step.
The cost is that platform-tools updates no longer arrive by bumping a pin; they
are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the
upstream SHA, the procedure, and which four files differ from upstream in line
endings only (social/*.js, .css, functions_publications.php — CRLF upstream,
normalised to LF here; content otherwise identical).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
335 lines
15 KiB
PHP
335 lines
15 KiB
PHP
<?php
|
|
// ─── Shared SMTP transport ───────────────────────────────────────────────────
|
|
//
|
|
// The TRANSPORT half of outbound email only: build a configured PHPMailer,
|
|
// apply the dev safety nets, hand the message to SMTP. Deliberately NOT the
|
|
// queue: fotografia's message_queue (21 cols, multi-channel, soft-delete),
|
|
// hub2's message_queue (19 cols, template-driven) and surf's
|
|
// notification_queue (12 cols, keyed on lesson_id + a JSON payload) encode app
|
|
// domain, not infrastructure, and have no useful common shape. Each app keeps
|
|
// its own queue and calls platform_mail_send() at the point of egress.
|
|
//
|
|
// Public API:
|
|
// platform_mail_config(): array
|
|
// platform_mail_available(string $repo_root = ''): bool
|
|
// platform_mail_make(string $repo_root = ''): ?PHPMailer
|
|
// platform_mail_send(array $msg, string $repo_root = ''): array
|
|
// platform_mail_test(string $repo_root = ''): array
|
|
|
|
/**
|
|
* Reads SMTP settings from whichever mechanism the host uses.
|
|
*
|
|
* Precedence: a constant already defined by the app wins (fotografia and hub2
|
|
* define SMTP_HOST et al. during bootstrap), then env()/getenv()/$_ENV for
|
|
* hosts that do not. Without this the toolkit would only work on one of them.
|
|
*/
|
|
/**
|
|
* $override lets a host supply settings the toolkit cannot discover.
|
|
*
|
|
* Not every install exposes SMTP through constants or the environment: hub2
|
|
* keeps them in system_settings with the password encrypted, reachable only
|
|
* through its own smtp_config(). Without this the transport would silently
|
|
* fall back to an unconfigured mailer on those hosts.
|
|
*/
|
|
function platform_mail_config(?array $override = null): array {
|
|
$get = function (string $key, string $default = '') {
|
|
if (defined($key)) {
|
|
$v = constant($key);
|
|
return is_bool($v) ? ($v ? '1' : '') : (string) $v;
|
|
}
|
|
if (function_exists('env')) {
|
|
$v = env($key, null);
|
|
if ($v !== null && $v !== '') return (string) $v;
|
|
}
|
|
$v = getenv($key);
|
|
if ($v === false || $v === '') $v = $_ENV[$key] ?? '';
|
|
return (string) ($v === false ? $default : ($v === '' ? $default : $v));
|
|
};
|
|
|
|
// Primeiro nome que resolver ganha. Os sites da quinta declaram
|
|
// SMTP_USER/SMTP_PASS/SMTP_FROM_EMAIL, este módulo nasceu a ler
|
|
// SMTP_USERNAME/SMTP_PASSWORD/EMAIL_FROM, e ninguém dava pela diferença: o
|
|
// envio real passa por platform_smtp_config($pdo), que lê a base. Só o
|
|
// diagnóstico é que lia daqui -- e mostrava utilizador e password vazios
|
|
// enquanto o email saía bem, que é precisamente a altura em que uma página
|
|
// de diagnóstico não pode mentir. Pior: platform_mail_send() sem override
|
|
// autenticava-se com credenciais vazias.
|
|
$first = function (array $keys, string $default = '') use ($get): string {
|
|
foreach ($keys as $k) {
|
|
$v = $get($k);
|
|
if ($v !== '') return $v;
|
|
}
|
|
return $default;
|
|
};
|
|
|
|
$cfg = [
|
|
'host' => $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 = '<div style="background:#fffae6;border:1px solid #e0c060;padding:10px 14px;'
|
|
. 'margin-bottom:16px;font-family:Arial,sans-serif;font-size:13px;color:#664400;">'
|
|
. '<strong>[DEV redirect]</strong> Destinatário original: '
|
|
. htmlspecialchars($orig, ENT_QUOTES, 'UTF-8') . '</div>';
|
|
$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],
|
|
];
|
|
}
|