Send mail locally again: message_queue + PHPMailer over SMTP

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>
This commit is contained in:
andreandClaude Opus 5 committed 2026-09-13 22:08:38 +01:00
1 parent 3626080599
commit d5cb50b432
16 files changed
+8982 -136

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
<?php
// ─── Secrets stored in system_settings ───────────────────────────────────────
//
// SMTP and Twilio credentials are configurable from the backoffice, which means
// they live in the database. That is a real tradeoff: the nightly job in
// cron.php keeps 30 database backups, so a plaintext password would sit in
// every one of them, and anyone who can read a dump would have the mail account.
//
// So secret-valued settings are encrypted at rest with AES-256-GCM. The key
// never goes in the database — it comes from .env, which is not in git and not
// in the DB dump. A dump on its own is therefore useless for recovering them.
//
// The cipher itself is no longer implemented here. It was one of two copies —
// hub2 had the same AES-256-GCM with a different key derivation, so a value
// written by one site would not open on the other. There is now a single
// implementation in shared/platform-tools; this file re-exports the short names
// (secret_encrypt, secret_decrypt, secret_is_encrypted, secret_mask,
// secrets_available, SECRET_PREFIX) that the sysadmin pages already call.
require_once dirname(__DIR__) . '/shared/platform-tools/compat_secrets.php';
// ─── Read helpers (site-specific, not crypto) ────────────────────────────────
//
// NOTE: these give .env precedence over the database, which is the opposite of
// platform_settings_get(). Deliberate here — an install can pin credentials in
// the environment and ignore the backoffice entirely — but it is a difference
// worth remembering when comparing behaviour with the other sites.
/**
* Read a secret-valued setting, decrypted.
*
* `.env` still wins when set, so an install can keep pinning credentials in the
* environment and ignore the DB entirely — that is the safer configuration and
* remains supported.
*/
function setting_secret(string $key, string $envKey = '', string $default = ''): string {
if ($envKey !== '') {
$fromEnv = (string) env($envKey, '');
if ($fromEnv !== '') return $fromEnv;
}
$stored = (string) setting($key, '');
return $stored !== '' ? secret_decrypt($stored) : $default;
}
/**
* Read a non-secret setting that may also be pinned in .env.
* Same precedence as setting_secret(), without the crypto.
*/
function setting_or_env(string $key, string $envKey, string $default = ''): string {
$fromEnv = (string) env($envKey, '');
if ($fromEnv !== '') return $fromEnv;
$stored = (string) setting($key, '');
return $stored !== '' ? $stored : $default;
}