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:
1 parent
3626080599
commit
d5cb50b432
16 files changed
+8982
-136
No files matched your search
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
// Comunicações — delivery credentials plus the diagnostics a sysadmin needs
|
||||
// when mail stops working.
|
||||
//
|
||||
// Config and tests live on one page deliberately: the loop is "change a
|
||||
// setting, test it, read the real error", and splitting that across two screens
|
||||
// makes it slower to diagnose.
|
||||
//
|
||||
// Sysadmin-only: it handles SMTP and Twilio credentials.
|
||||
|
||||
require __DIR__ . '/../../config/db.php';
|
||||
require __DIR__ . '/../../config/functions.php';
|
||||
$user = require_sysadmin();
|
||||
|
||||
require_once __DIR__ . '/../../shared/platform-tools/functions_mail.php';
|
||||
require_once __DIR__ . '/../../shared/platform-tools/functions_settings.php';
|
||||
|
||||
$repo_root = dirname(__DIR__, 2);
|
||||
$saved = false;
|
||||
$errors = [];
|
||||
$test = null;
|
||||
|
||||
// Secret fields are write-only in the UI and encrypted at rest. Which keys count
|
||||
// as secret is decided by platform_settings_is_secret() — anything ending in
|
||||
// _password/_secret/_token/_api_key — rather than a list maintained here, so a
|
||||
// field added later can't accidentally be stored in the clear.
|
||||
$fields = [
|
||||
'smtp_host' => ['label' => 'Servidor SMTP', 'type' => 'text', 'hint' => 'Vazio = usa a função mail() do PHP.'],
|
||||
'smtp_port' => ['label' => 'Porta', 'type' => 'number', 'hint' => '587 para TLS, 465 para SSL.'],
|
||||
'smtp_secure' => ['label' => 'Encriptação', 'type' => 'select', 'options' => ['tls' => 'TLS (StartTLS)', 'ssl' => 'SSL', '' => 'Nenhuma']],
|
||||
'smtp_username' => ['label' => 'Utilizador', 'type' => 'text', 'hint' => 'Normalmente o endereço de email completo.'],
|
||||
'smtp_password' => ['label' => 'Palavra-passe', 'type' => 'password'],
|
||||
'email_from' => ['label' => 'Remetente', 'type' => 'text', 'hint' => 'Endereço que aparece no From.'],
|
||||
'email_from_name' => ['label' => 'Nome do remetente', 'type' => 'text'],
|
||||
'twilio_sid' => ['label' => 'Account SID', 'type' => 'text', 'hint' => 'Começa por AC…'],
|
||||
'twilio_token' => ['label' => 'Auth Token', 'type' => 'password'],
|
||||
'twilio_sms_from' => ['label' => 'Remetente SMS', 'type' => 'text', 'hint' => 'E.164 (+351…) ou alfanumérico.'],
|
||||
'twilio_whatsapp_from' => ['label' => 'Remetente WhatsApp', 'type' => 'text', 'hint' => 'Número E.164, sem o prefixo whatsapp:.'],
|
||||
'sms_default_country_code' => ['label' => 'Indicativo por defeito', 'type' => 'text', 'hint' => 'Aplicado a números guardados sem +.'],
|
||||
];
|
||||
|
||||
$groups = [
|
||||
'SMTP' => ['smtp_host', 'smtp_port', 'smtp_secure', 'smtp_username', 'smtp_password', 'email_from', 'email_from_name'],
|
||||
'Twilio (SMS / WhatsApp)' => ['twilio_sid', 'twilio_token', 'twilio_sms_from', 'twilio_whatsapp_from', 'sms_default_country_code'],
|
||||
];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
$action = $_POST['_action'] ?? 'save';
|
||||
$uid = (int) $user['id'];
|
||||
|
||||
if ($action === 'save') {
|
||||
foreach ($fields as $key => $meta) {
|
||||
$raw = trim((string) ($_POST[$key] ?? ''));
|
||||
|
||||
if (platform_settings_is_secret($key)) {
|
||||
// Blank means "leave the stored one alone" — the field is never
|
||||
// pre-filled with the real value, so blank cannot mean "clear".
|
||||
// Clearing is an explicit checkbox.
|
||||
if (!empty($_POST[$key . '_clear'])) {
|
||||
db_update_setting($pdo, $key, '', $uid);
|
||||
} elseif ($raw !== '') {
|
||||
db_update_setting($pdo, $key, secret_encrypt($raw), $uid);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
db_update_setting($pdo, $key, $raw, $uid);
|
||||
}
|
||||
// db_update_setting() drops fitme3070's file cache; platform_settings_get()
|
||||
// keeps its own per-request cache, which must go too or the diagnostics
|
||||
// below would render the values from before this save.
|
||||
platform_settings_cache_clear();
|
||||
$saved = true;
|
||||
} elseif ($action === 'test') {
|
||||
$test = platform_mail_test($repo_root, platform_smtp_config($pdo));
|
||||
}
|
||||
}
|
||||
|
||||
$current = settings_all();
|
||||
[$diagClass, $diagTitle, $diagText] = platform_mail_diagnosis($repo_root, null, $pdo);
|
||||
$rows = platform_mail_config_rows(null, $pdo);
|
||||
|
||||
$page_title = 'Comunicações';
|
||||
include __DIR__ . '/../../includes/header.php';
|
||||
?>
|
||||
<div class="container" style="max-width:860px;">
|
||||
<h1 class="h3 mb-1">Comunicações</h1>
|
||||
<p class="text-muted mb-4">Credenciais de envio e diagnóstico. As palavras-passe são guardadas cifradas.</p>
|
||||
|
||||
<?php if ($saved): ?><div class="alert alert-success">Definições guardadas.</div><?php endif; ?>
|
||||
<?php foreach ($errors as $e): ?><div class="alert alert-danger"><?= htmlspecialchars($e) ?></div><?php endforeach; ?>
|
||||
|
||||
<?php if (!platform_settings_secret_available()): ?>
|
||||
<div class="alert alert-warning">
|
||||
<strong>Cifra indisponível.</strong> Defina <code>APP_SECRET_KEY</code> no <code>.env</code>
|
||||
(ou um <code>BACKUP_TOKEN</code> que não seja <code>replace_me</code>) — sem chave, as palavras-passe
|
||||
ficam guardadas em texto simples e acabam nos backups da base de dados.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- ─── Estado ─────────────────────────────────────────────────────── -->
|
||||
<div class="card shadow-sm mb-3"><div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 class="h6 mb-1">Estado</h2>
|
||||
<span class="badge text-bg-<?= htmlspecialchars($diagClass) ?>"><?= htmlspecialchars($diagTitle) ?></span>
|
||||
<p class="text-muted small mb-0 mt-2"><?= htmlspecialchars($diagText) ?></p>
|
||||
</div>
|
||||
<form method="POST" class="ms-auto">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="_action" value="test">
|
||||
<button class="btn btn-outline-primary"><i class="bi bi-plug me-1"></i>Testar ligação SMTP</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<?php if ($test !== null): ?>
|
||||
<hr>
|
||||
<div class="alert alert-<?= !empty($test['ok']) ? 'success' : 'danger' ?> py-2 small mb-2">
|
||||
<?= !empty($test['ok']) ? 'Ligação estabelecida.' : htmlspecialchars((string) ($test['error'] ?? 'Falhou.')) ?>
|
||||
</div>
|
||||
<?php if (!empty($test['log'])): ?>
|
||||
<details>
|
||||
<summary class="small text-muted">Registo da ligação</summary>
|
||||
<pre class="small bg-light p-2 mt-2 mb-0" style="max-height:260px;overflow:auto;white-space:pre-wrap;"><?= htmlspecialchars((string) $test['log']) ?></pre>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<hr>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody>
|
||||
<?php foreach ($rows as [$label, $value, $ok]): ?>
|
||||
<tr>
|
||||
<th class="text-muted fw-normal" style="width:40%;"><?= htmlspecialchars($label) ?></th>
|
||||
<td>
|
||||
<?= htmlspecialchars((string) $value) ?>
|
||||
<?php if (!$ok): ?><i class="bi bi-exclamation-triangle-fill text-warning ms-1"></i><?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-muted small mb-0 mt-2">
|
||||
Valores pinados no <code>.env</code> têm precedência sobre estes campos — se editar aqui e nada mudar,
|
||||
é porque a chave está definida no <code>.env</code>.
|
||||
</p>
|
||||
</div></div>
|
||||
|
||||
<!-- ─── Definições ─────────────────────────────────────────────────── -->
|
||||
<form method="POST">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="_action" value="save">
|
||||
<?php foreach ($groups as $groupLabel => $keys): ?>
|
||||
<div class="card shadow-sm mb-3"><div class="card-body">
|
||||
<h2 class="h6 mb-3"><?= htmlspecialchars($groupLabel) ?></h2>
|
||||
<?php foreach ($keys as $key):
|
||||
$meta = $fields[$key];
|
||||
$isSecret = platform_settings_is_secret($key);
|
||||
$stored = (string) ($current[$key] ?? '');
|
||||
?>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="f-<?= htmlspecialchars($key) ?>"><?= htmlspecialchars($meta['label']) ?></label>
|
||||
<?php if ($meta['type'] === 'select'): ?>
|
||||
<select class="form-select" id="f-<?= htmlspecialchars($key) ?>" name="<?= htmlspecialchars($key) ?>">
|
||||
<?php foreach ($meta['options'] as $val => $lbl): ?>
|
||||
<option value="<?= htmlspecialchars((string) $val) ?>" <?= $stored === (string) $val ? 'selected' : '' ?>><?= htmlspecialchars($lbl) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php elseif ($isSecret): ?>
|
||||
<input type="password" class="form-control" id="f-<?= htmlspecialchars($key) ?>"
|
||||
name="<?= htmlspecialchars($key) ?>" autocomplete="new-password"
|
||||
placeholder="<?= $stored !== '' ? '•••••••• (guardada — deixe vazio para manter)' : 'não definida' ?>">
|
||||
<?php if ($stored !== ''): ?>
|
||||
<div class="form-check small mt-1">
|
||||
<input class="form-check-input" type="checkbox" value="1"
|
||||
id="c-<?= htmlspecialchars($key) ?>" name="<?= htmlspecialchars($key) ?>_clear">
|
||||
<label class="form-check-label text-muted" for="c-<?= htmlspecialchars($key) ?>">Apagar a guardada</label>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<input type="<?= htmlspecialchars($meta['type']) ?>" class="form-control"
|
||||
id="f-<?= htmlspecialchars($key) ?>" name="<?= htmlspecialchars($key) ?>"
|
||||
value="<?= htmlspecialchars($stored) ?>">
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($meta['hint'])): ?>
|
||||
<div class="form-text"><?= htmlspecialchars($meta['hint']) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div></div>
|
||||
<?php endforeach; ?>
|
||||
<button class="btn btn-primary">Guardar</button>
|
||||
<a class="btn btn-link" href="<?= base_url('views/sysadmin/messages.php') ?>">Ver fila de mensagens →</a>
|
||||
</form>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../includes/footer.php'; ?>
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
// Monitor da fila de mensagens — o que saiu, o que falhou e porquê.
|
||||
//
|
||||
// The queue is only useful if someone can see into it: a message that failed
|
||||
// three times and parked in 'failed' is invisible everywhere else in the app,
|
||||
// and last_error is the only place the real SMTP rejection is written down.
|
||||
//
|
||||
// Sysadmin-only: message bodies contain personal data.
|
||||
|
||||
require __DIR__ . '/../../config/db.php';
|
||||
require __DIR__ . '/../../config/functions.php';
|
||||
$user = require_sysadmin();
|
||||
|
||||
$notice = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$action = $_POST['_action'] ?? '';
|
||||
|
||||
if ($id > 0) {
|
||||
switch ($action) {
|
||||
case 'retry':
|
||||
// Back to 'pending' with the attempt counter cleared, then sent
|
||||
// inline so the operator sees the outcome now rather than
|
||||
// waiting for the next cron tick.
|
||||
db_retry_message($pdo, $id);
|
||||
$notice = flush_message($id)
|
||||
? "Mensagem #{$id} enviada."
|
||||
: "Mensagem #{$id} voltou a falhar — veja o erro na linha.";
|
||||
break;
|
||||
case 'cancel':
|
||||
db_cancel_message($pdo, $id);
|
||||
$notice = "Mensagem #{$id} cancelada.";
|
||||
break;
|
||||
case 'delete':
|
||||
db_soft_delete_message($pdo, $id);
|
||||
$notice = "Mensagem #{$id} arquivada.";
|
||||
break;
|
||||
case 'restore':
|
||||
db_restore_message($pdo, $id);
|
||||
$notice = "Mensagem #{$id} restaurada.";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$status = (string) ($_GET['status'] ?? '');
|
||||
$channel = (string) ($_GET['channel'] ?? '');
|
||||
$search = trim((string) ($_GET['q'] ?? ''));
|
||||
$trash = !empty($_GET['trash']);
|
||||
$detail = (int) ($_GET['id'] ?? 0);
|
||||
|
||||
$counts = db_count_messages_by_status($pdo, 30);
|
||||
// db_get_messages() takes a `statuses` ARRAY and `only_deleted`, not the
|
||||
// singular/`trash` names the query string uses.
|
||||
$messages = db_get_messages($pdo, [
|
||||
'statuses' => $status !== '' ? [$status] : [],
|
||||
'channel' => $channel ?: null,
|
||||
'search' => $search ?: null,
|
||||
'only_deleted' => $trash,
|
||||
'limit' => 200,
|
||||
]);
|
||||
$row = $detail > 0 ? db_get_message($pdo, $detail) : false;
|
||||
|
||||
$page_title = 'Mensagens';
|
||||
include __DIR__ . '/../../includes/header.php';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<h1 class="h3 mb-0">Mensagens</h1>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="<?= base_url('views/sysadmin/comms.php') ?>">
|
||||
<i class="bi bi-sliders me-1"></i>Configuração SMTP
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<?php if ($notice): ?><div class="alert alert-info"><?= htmlspecialchars($notice) ?></div><?php endif; ?>
|
||||
|
||||
<!-- ─── Contadores (últimos 30 dias) ───────────────────────────────── -->
|
||||
<div class="row g-2 mb-3">
|
||||
<?php foreach (['pending' => 'Em espera', 'sending' => 'A enviar', 'sent' => 'Enviadas', 'failed' => 'Falhadas', 'cancelled' => 'Canceladas'] as $k => $lbl): ?>
|
||||
<div class="col-6 col-md">
|
||||
<a class="text-decoration-none" href="<?= base_url('views/sysadmin/messages.php?status=' . $k) ?>">
|
||||
<div class="card shadow-sm h-100"><div class="card-body py-2 px-3">
|
||||
<div class="text-muted small"><?= htmlspecialchars($lbl) ?></div>
|
||||
<div class="fs-4"><?= (int) ($counts[$k] ?? 0) ?></div>
|
||||
</div></div>
|
||||
</a>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- ─── Filtros ────────────────────────────────────────────────────── -->
|
||||
<form class="card shadow-sm mb-3" method="GET"><div class="card-body py-2">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1" for="f-status">Estado</label>
|
||||
<select class="form-select form-select-sm" id="f-status" name="status">
|
||||
<option value="">Todos</option>
|
||||
<?php foreach (MESSAGE_QUEUE_VALID_STATUSES as $s): ?>
|
||||
<option value="<?= $s ?>" <?= $status === $s ? 'selected' : '' ?>><?= htmlspecialchars(message_status_label($s)) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1" for="f-channel">Canal</label>
|
||||
<select class="form-select form-select-sm" id="f-channel" name="channel">
|
||||
<option value="">Todos</option>
|
||||
<?php foreach (MESSAGE_QUEUE_VALID_CHANNELS as $c): ?>
|
||||
<option value="<?= $c ?>" <?= $channel === $c ? 'selected' : '' ?>><?= htmlspecialchars(message_channel_label($c)) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small mb-1" for="f-q">Pesquisar</label>
|
||||
<input class="form-control form-control-sm" id="f-q" name="q" value="<?= htmlspecialchars($search) ?>" placeholder="destinatário ou assunto">
|
||||
</div>
|
||||
<div class="col-md-2 d-grid">
|
||||
<button class="btn btn-sm btn-primary">Filtrar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check small mt-2">
|
||||
<input class="form-check-input" type="checkbox" value="1" id="f-trash" name="trash" <?= $trash ? 'checked' : '' ?>>
|
||||
<label class="form-check-label text-muted" for="f-trash">Mostrar arquivadas</label>
|
||||
</div>
|
||||
</div></form>
|
||||
|
||||
<!-- ─── Detalhe ────────────────────────────────────────────────────── -->
|
||||
<?php if ($row): ?>
|
||||
<div class="card shadow-sm mb-3"><div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<h2 class="h6 mb-2">#<?= (int) $row['id'] ?> · <?= htmlspecialchars($row['subject'] ?? '(sem assunto)') ?></h2>
|
||||
<a class="btn-close" href="<?= base_url('views/sysadmin/messages.php') ?>" aria-label="Fechar"></a>
|
||||
</div>
|
||||
<dl class="row small mb-2">
|
||||
<dt class="col-sm-3 fw-normal text-muted">Para</dt>
|
||||
<dd class="col-sm-9"><?= htmlspecialchars($row['to_address']) ?><?= $row['to_name'] ? ' (' . htmlspecialchars($row['to_name']) . ')' : '' ?></dd>
|
||||
<dt class="col-sm-3 fw-normal text-muted">Estado</dt>
|
||||
<dd class="col-sm-9"><?= message_status_badge($row['status']) ?> · <?= (int) $row['attempts'] ?>/<?= (int) $row['max_attempts'] ?> tentativas</dd>
|
||||
<dt class="col-sm-3 fw-normal text-muted">Contexto</dt>
|
||||
<dd class="col-sm-9"><?= htmlspecialchars(message_context_label($row['context_type'])) ?></dd>
|
||||
<?php if (!empty($row['last_error'])): ?>
|
||||
<dt class="col-sm-3 fw-normal text-muted">Último erro</dt>
|
||||
<dd class="col-sm-9"><code class="text-danger"><?= htmlspecialchars($row['last_error']) ?></code></dd>
|
||||
<?php endif; ?>
|
||||
</dl>
|
||||
<details>
|
||||
<summary class="small text-muted">Corpo da mensagem</summary>
|
||||
<pre class="small bg-light p-2 mt-2 mb-0" style="max-height:340px;overflow:auto;white-space:pre-wrap;"><?= htmlspecialchars($row['body']) ?></pre>
|
||||
</details>
|
||||
</div></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- ─── Lista ──────────────────────────────────────────────────────── -->
|
||||
<div class="card shadow-sm"><div class="card-body">
|
||||
<?php if (!$messages): ?>
|
||||
<p class="text-muted text-center py-4 mb-0">Sem mensagens.</p>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle small">
|
||||
<thead><tr>
|
||||
<th>#</th><th>Canal</th><th>Para</th><th>Assunto</th>
|
||||
<th>Estado</th><th>Criada</th><th class="text-end">Ações</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($messages as $m): ?>
|
||||
<tr>
|
||||
<td><a href="<?= base_url('views/sysadmin/messages.php?id=' . (int) $m['id']) ?>">#<?= (int) $m['id'] ?></a></td>
|
||||
<td><i class="bi <?= htmlspecialchars(message_channel_icon($m['channel'])) ?>" title="<?= htmlspecialchars(message_channel_label($m['channel'])) ?>"></i></td>
|
||||
<td><?= htmlspecialchars($m['to_address']) ?></td>
|
||||
<td class="text-truncate" style="max-width:260px;"><?= htmlspecialchars($m['subject'] ?? '—') ?></td>
|
||||
<td>
|
||||
<?= message_status_badge($m['status']) ?>
|
||||
<?php if (!empty($m['last_error'])): ?>
|
||||
<i class="bi bi-exclamation-circle text-danger ms-1" title="<?= htmlspecialchars(mb_substr($m['last_error'], 0, 200)) ?>"></i>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-muted"><?= htmlspecialchars(format_date_pt($m['created_at'])) ?></td>
|
||||
<td class="text-end text-nowrap">
|
||||
<?php if (!$trash): ?>
|
||||
<?php if (in_array($m['status'], ['failed', 'cancelled', 'pending'], true)): ?>
|
||||
<form method="POST" class="d-inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= (int) $m['id'] ?>">
|
||||
<input type="hidden" name="_action" value="retry">
|
||||
<button class="btn btn-sm btn-outline-primary" title="Reenviar agora"><i class="bi bi-arrow-repeat"></i></button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php if (in_array($m['status'], ['pending', 'failed'], true)): ?>
|
||||
<form method="POST" class="d-inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= (int) $m['id'] ?>">
|
||||
<input type="hidden" name="_action" value="cancel">
|
||||
<button class="btn btn-sm btn-outline-secondary" title="Cancelar"><i class="bi bi-slash-circle"></i></button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<form method="POST" class="d-inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= (int) $m['id'] ?>">
|
||||
<input type="hidden" name="_action" value="delete">
|
||||
<button class="btn btn-sm btn-outline-danger" title="Arquivar"><i class="bi bi-archive"></i></button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<form method="POST" class="d-inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= (int) $m['id'] ?>">
|
||||
<input type="hidden" name="_action" value="restore">
|
||||
<button class="btn btn-sm btn-outline-secondary" title="Restaurar"><i class="bi bi-arrow-counterclockwise"></i></button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-muted small mb-0">
|
||||
Mostradas até 200. A fila é drenada pelo <code>cron.php</code>; as falhas voltam a ser tentadas
|
||||
com espera exponencial até <code>max_attempts</code>.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div></div>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../includes/footer.php'; ?>
|
||||
Reference in new issue
Block a user