Outbound email was relayed to the hub's /notify endpoint, so this install had
no SMTP configuration of its own and no way to see what had been sent. Bring
delivery back in-house, following the fotografia.andmore.pt stack rather than
inventing a second shape for it.
- Submodule bumped 603ff84 -> ea3616a, the pin fotografia already uses. That
is what provides functions_mail.php (shared transport), functions_settings.php
(platform_smtp_config + encrypted settings) and compat_secrets.php. Verified
no function-name collisions with this repo's own config/functions_settings.php:
platform-tools namespaces everything platform_*.
- PHPMailer 7.1.1 vendored at vendor/PHPMailer/src/. platform_mail_available()
looks for exactly this hand-vendored layout before falling back to composer's
autoloader, so the host needs no shell and no composer install. LGPL-2.1
license text included.
- config/functions_queue.php replaced: was a thin facade over the hub relay,
now fotografia's real queue. Rows are claimed atomically, retried with
exponential backoff up to max_attempts, and park in 'failed' with the real
SMTP rejection in last_error.
- SMTP resolution matches the fleet: .env wins for the SMTP_* constants, while
the actual send goes through platform_smtp_config($pdo), which is DB-first.
That ordering is deliberate -- the database is the half an operator can
change from the backoffice without shell access. Empty SMTP_HOST falls back
to PHP mail().
- Sistema -> Comunicações (views/sysadmin/comms.php): credentials plus a live
SMTP connection test and a verdict on what would happen to a message sent
right now. Secret-valued settings are write-only in the form and encrypted
at rest (AES-256-GCM, key from .env, never in the DB dump). Which keys count
as secret is platform_settings_is_secret()'s decision, not a list kept here.
- Sistema -> Mensagens (views/sysadmin/messages.php): the queue monitor.
Filters, per-message detail with last_error, and retry/cancel/archive.
Retry flushes inline so the operator sees the outcome immediately.
- cron.php drains the queue every 5 minutes, serialised with a lock file, and
runs retention once a day. Over HTTP it answers 404 until CRON_TOKEN is set
to something other than replace_me. Deliberately not a copy of fotografia's
cron: the session-reminder, download-digest and photo-archive steps have no
modules here and would be dead code.
- config/functions_notify_client.php deleted, and ANDMORE_ID_URL with it --
the hub relay was its only caller.
Verified without a database: PHP lint across the tree; PHPMailer builds a full
MIME message with RFC 2047 UTF-8 subject encoding from the three vendored files;
platform_mail_available() finds them; platform_mail_make() returns a correctly
configured mailer; the diagnosis states report Operacional / Não configurado.
NOT verified: the settings encryption round-trip -- this machine's PHP has no
openssl extension, so platform_secret_key() returns null and values pass through
in clear. The code degrades safely (stores the value rather than losing it) and
the Comunicações page warns when the key is unavailable.
Deployment: apply database/migration_20260913_message_queue.sql, set SMTP in
Comunicações or .env, set APP_SECRET_KEY, and schedule cron.php.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
106 lines
4.9 KiB
PHP
106 lines
4.9 KiB
PHP
<?php
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Scheduled maintenance runner. Designed to be fired every 5 minutes.
|
|
//
|
|
// Shell cron (preferred — no token, no web exposure):
|
|
// */5 * * * * /usr/bin/php /path/to/fitme3070.pt/cron.php >/dev/null 2>&1
|
|
//
|
|
// No shell? Hit it over HTTP with the shared secret from .env:
|
|
// */5 * * * * curl -fsS "https://fitme3070.pt/cron.php?token=<CRON_TOKEN>"
|
|
//
|
|
// What it does, in order:
|
|
// 1. Releases rows stuck in 'sending' (a worker died mid-send).
|
|
// 2. Drains the message queue (email via SMTP, SMS/WhatsApp via Twilio).
|
|
// 3. Soft-deletes messages sent more than 90 days ago, then hard-deletes
|
|
// rows that have been soft-deleted for another 90.
|
|
//
|
|
// Every run is serialised with a lock file, so a slow SMTP server cannot cause
|
|
// two runs to drain the same rows. flush_message() claims each row atomically
|
|
// as well, so an inline send racing the cron is also safe — the lock is about
|
|
// not piling up runs, not about correctness.
|
|
//
|
|
// Deliberately NOT a copy of fotografia's cron: that one also queues session
|
|
// reminders, emails a download digest and compresses session photos. fitme3070
|
|
// has none of those modules, so the steps would be dead code.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
$cli = (PHP_SAPI === 'cli');
|
|
|
|
require __DIR__ . '/config/db.php';
|
|
require __DIR__ . '/config/functions.php';
|
|
|
|
// ─── Access control (HTTP only) ──────────────────────────────────────────────
|
|
// Over the web the token is the only thing standing between the internet and a
|
|
// job that sends mail. Missing or wrong token = 404, not 403: an attacker
|
|
// probing for cron endpoints learns nothing from a 404.
|
|
if (!$cli) {
|
|
$expected = (string) env('CRON_TOKEN', '');
|
|
$supplied = (string) ($_GET['token'] ?? '');
|
|
if ($expected === '' || $expected === 'replace_me'
|
|
|| !hash_equals($expected, $supplied)) {
|
|
http_response_code(404);
|
|
exit("Not Found\n");
|
|
}
|
|
header('Content-Type: text/plain; charset=utf-8');
|
|
}
|
|
|
|
// ─── Single-run lock ─────────────────────────────────────────────────────────
|
|
$lockPath = __DIR__ . '/logs/cron.lock';
|
|
@mkdir(dirname($lockPath), 0775, true);
|
|
$lock = @fopen($lockPath, 'c');
|
|
if ($lock === false) {
|
|
echo "cron: não foi possível abrir o ficheiro de lock\n";
|
|
exit(1);
|
|
}
|
|
if (!flock($lock, LOCK_EX | LOCK_NB)) {
|
|
echo "cron: já existe uma execução a decorrer — saltada.\n";
|
|
exit(0);
|
|
}
|
|
|
|
$started = microtime(true);
|
|
$log = [];
|
|
function cron_log(string $line): void {
|
|
global $log;
|
|
$log[] = $line;
|
|
echo $line . "\n";
|
|
}
|
|
|
|
cron_log('── cron ' . date('Y-m-d H:i:s') . ' ──');
|
|
|
|
// ─── 1. Release rows stuck in 'sending' ──────────────────────────────────────
|
|
try {
|
|
$released = requeue_stuck_messages($pdo, 15);
|
|
if ($released > 0) cron_log("mensagens libertadas de 'sending': {$released}");
|
|
} catch (Throwable $e) {
|
|
cron_log('ERRO ao libertar mensagens presas: ' . $e->getMessage());
|
|
}
|
|
|
|
// ─── 2. Drain the queue ──────────────────────────────────────────────────────
|
|
try {
|
|
$r = process_queue(50);
|
|
if ($r['total'] > 0) {
|
|
cron_log("fila: {$r['total']} processada(s), {$r['sent']} enviada(s), {$r['failed']} falhada(s)");
|
|
}
|
|
} catch (Throwable $e) {
|
|
cron_log('ERRO ao drenar a fila: ' . $e->getMessage());
|
|
}
|
|
|
|
// ─── 3. Retention ────────────────────────────────────────────────────────────
|
|
// Once a day is plenty. Gated on the first 5-minute slot of 04:00 — the hour
|
|
// alone would still fire this twelve times. Bodies are longtext, so this is
|
|
// what stops the table growing without bound.
|
|
if ((int) date('G') === 4 && (int) date('i') < 5) {
|
|
try {
|
|
$soft = db_soft_delete_sent_older_than($pdo, 90);
|
|
$hard = db_purge_deleted_messages($pdo, 90);
|
|
if ($soft || $hard) cron_log("retenção: {$soft} arquivada(s), {$hard} eliminada(s)");
|
|
} catch (Throwable $e) {
|
|
cron_log('ERRO na retenção: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
cron_log(sprintf('── fim (%.2fs) ──', microtime(true) - $started));
|
|
|
|
flock($lock, LOCK_UN);
|
|
fclose($lock);
|