Soporte, staff, permisos y correo saliente

Por: Artiko
fossbillingsoporteticketshelpdeskstaffpermisosemailsmtpsymfony-mailer2faantispam

Soporte, staff, permisos y correo saliente

En el capítulo 8 dejaste el ciclo de dinero cerrado: facturas que se generan solas, recordatorios que salen, transacciones que acreditan. Ahí aparece el primer problema real de operación: casi todo eso depende de que salga un correo. Si la cola de email está parada, el cliente no recibe la factura, ni el aviso de vencimiento, ni la respuesta a su ticket, ni el aviso de que su servicio se suspende. La facturación funciona en silencio y tú te enteras cuando alguien reclama.

El segundo problema aparece cuando el negocio deja de ser una persona: necesitas que alguien conteste tickets sin poder tocar facturas, borrar clientes ni ver credenciales de pasarelas. FOSSBilling 0.8.4 reescribió por completo el modelo de permisos de administradores, pasando de permisos por usuario a permisos por grupo con herencia; cualquier captura o tutorial anterior a julio de 2026 describe una pantalla que ya no existe. Y hay un tercer asunto incómodo que este capítulo dice sin rodeos: FOSSBilling 0.8.5 no tiene autenticación de dos factores. Ni TOTP, ni WebAuthn, ni códigos de respaldo. Eso obliga a montar la segunda capa fuera de la aplicación.

Al final tendrás departamentos de soporte con auto-cierre real, respuestas predefinidas, formularios públicos protegidos contra spam, un grupo de staff con permisos mínimos, el panel detrás de una barrera adicional y correo saliente que sale de verdad, con un procedimiento de diagnóstico que ataca las causas en el orden en que ocurren. La versión de referencia es FOSSBilling 0.8.5.

El módulo Support por dentro: tickets, mensajes y notas internas

El módulo vive en src/modules/Support/. La entidad central es Support/Entity/SupportTicket.php, sobre la tabla support_ticket:

CampoTipoPara qué sirve
support_helpdesk_idintdepartamento al que pertenece el ticket
client_idint nullablesi es NULL, es un ticket de invitado
access_hashvarcharpermite abrir el ticket sin sesión, por enlace
author_name, author_emailvarcharidentidad del autor cuando no hay cliente
priorityint, default 100orden de la bandeja; menor número, más arriba
subjectvarcharasunto
statusvarcharopen, on_hold o closed
rel_type, rel_id, rel_task, rel_new_value, rel_statusvariostarea pendiente adjunta al ticket

Los cinco campos rel_* son la parte menos conocida: permiten que un ticket lleve adjunta una acción pendiente sobre otro objeto del sistema, por ejemplo un cambio de nameservers a la espera de aprobación. El endpoint support/task_complete es el que la ejecuta desde el propio ticket.

Del ticket cuelgan dos colecciones distintas: messages (tabla support_ticket_message), que es la conversación visible para el cliente, y notes (tabla support_ticket_note), visible solo para el staff y gestionada con support/note_create y support/note_delete. La confusión clásica es escribir la nota interna con support/ticket_reply: eso lo lee el cliente.

Rutas del panel: /admin/support, /admin/support/ticket/:id y /admin/support/ticket/:id/message/:messageid. La API de administración (src/modules/Support/Api/Admin.php) expone:

support/ticket_get_list      support/ticket_get        support/ticket_update
support/ticket_reply         support/ticket_close      support/ticket_create
support/ticket_delete        support/ticket_get_statuses
support/ticket_message_update   support/ticket_message_history_get_list
support/batch_ticket_auto_close support/batch_delete
support/note_create          support/note_delete       support/task_complete

El par ticket_message_update + ticket_message_history_get_list llegó en 0.8.4: puedes editar una respuesta ya publicada y queda el historial de revisiones. Útil cuando alguien pega una credencial por error, pero recuerda que la revisión anterior sigue guardada.

Estados del ticket y por qué solo se auto-cierran los on_hold

Las constantes están en la propia entidad y son tres, no más:

public const STATUS_OPEN   = 'open';
public const STATUS_ONHOLD = 'on_hold';
public const STATUS_CLOSED = 'closed';

Aquí está el detalle que rompe la expectativa de casi todo el mundo: el auto-cierre no mira los tickets abiertos. La consulta real de SupportTicketRepository::findExpiredOnHold() es esta:

SELECT st.* FROM support_ticket AS st
LEFT JOIN support_helpdesk sh ON sh.id = st.support_helpdesk_id
WHERE st.status = 'on_hold'
  AND DATE_ADD(st.updated_at, INTERVAL sh.close_after HOUR) < :now
ORDER BY st.id ASC

De ahí salen cuatro reglas duras. Uno. El filtro es status = 'on_hold'; un ticket en open que lleva seis meses muerto no se cierra jamás por sí solo. on_hold es el estado que pone el sistema cuando el staff responde y la pelota queda en el tejado del cliente. Dos. close_after está en horas, no en días: si querías cerrar a los 7 días el valor es 168, y poner 7 cierra los tickets a las siete horas de la última respuesta. Tres. Si close_after es NULL, el DATE_ADD devuelve NULL, la comparación nunca se cumple y el ticket no se cierra nunca; un departamento recién creado sin ese campo acumula on_hold indefinidamente. Cuatro. El barrido lo dispara la tarea support_batch_ticket_auto_close dentro de Cron\Service::runCrons(): sin cron no hay auto-cierre. Si montaste el cron siguiendo el capítulo 3 esto ya corre; si no, revisa last_cron_exec en System → Cron y el canal data/log/cron/.

stateDiagram-v2
    [*] --> open: cliente o invitado abre el ticket
    open --> on_hold: el staff responde
    open --> closed: cierre manual
    on_hold --> open: el cliente responde de nuevo
    on_hold --> closed: support_batch_ticket_auto_close
    closed --> open: reapertura solo si can_reopen es true
    closed --> [*]
    note right of on_hold
        Auto cierre si DATE_ADD updated_at
        INTERVAL close_after HOUR es menor que NOW
        close_after NULL significa nunca
    end note

Departamentos (helpdesks): email, firma, can_reopen y close_after en horas

Un helpdesk es un departamento de soporte, entidad Support/Entity/Helpdesk.php sobre la tabla support_helpdesk:

ColumnaTipoSignificado
namevarchar(255)nombre visible en el desplegable del formulario
emailvarchar(255)correo del departamento
can_reopenbool nullablepermite que el cliente reabra un ticket cerrado
close_aftersmallint nullablehoras en on_hold antes del auto-cierre
signaturetextfirma que se añade a las respuestas del departamento

Rutas: /admin/support/helpdesks y /admin/support/helpdesk/:id. La API es support/helpdesk_create (el único parámetro requerido es name), helpdesk_update, helpdesk_get, helpdesk_get_list, helpdesk_get_pairs y helpdesk_delete, todas bajo el permiso support:manage_helpdesk. Un alta desde la API, con la autenticación HTTP Basic donde el usuario es el rol y la contraseña el token:

curl -u 'admin:TU_API_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"name":"Facturacion","email":"[email protected]","close_after":72,"can_reopen":true}' \
  https://tu-dominio/api/admin/support/helpdesk_create

close_after: 72 son tres días, un valor razonable para un helpdesk comercial; para uno técnico, donde el cliente tiene que probar algo, 168 funciona mejor. Y can_reopen: false en facturación evita que un ticket cerrado hace ocho meses vuelva a la bandeja con una consulta nueva. Gotcha: revisa con SELECT id, name, close_after, can_reopen FROM support_helpdesk; — cualquier fila con close_after a NULL es una fuga silenciosa de tickets en on_hold.

Tickets de invitado: la unificación de 0.8.3 y cómo desactivarlos

Hasta 0.8.2 los tickets públicos y los de cliente eran dos flujos separados con eventos propios. 0.8.3 los unificó: hoy es la misma tabla, el mismo ciclo de vida y los mismos eventos. Lo que distingue a un ticket de invitado es que client_id es NULL, que la identidad viene de author_name / author_email y que el acceso posterior se hace por access_hash.

La consecuencia para quien tenga integraciones es que cuatro eventos desaparecieron:

Evento eliminado en 0.8.3Sustituto
onBeforeGuestPublicTicketOpenonBeforeClientOpenTicket
onAfterGuestPublicTicketOpenonAfterClientOpenTicket
onAfterGuestPublicTicketCloseonAfterClientCloseTicket
onAfterGuestPublicTicketReplyonAfterClientReplyTicket

En el mismo movimiento se renombraron los del lado admin: onBeforeAdminPublicTicketOpen pasó a onBeforeAdminOpenTicket, y los de cierre y respuesta a onAfterAdminCloseTicket y onAfterAdminReplyTicket. Si tienes un listener escuchando los nombres viejos no falla: simplemente nunca se ejecuta. El sistema de hooks se trata en el capítulo 14.

Los endpoints públicos del módulo están en Api/Guest.php y son ticket_create, ticket_get, ticket_close, ticket_reply, guest_tickets_enabled, public_tickets_enabled, helpdesk_get_pairs y toda la familia kb_*. Desde 0.8.0, guest/support/ticket_create exige name, email, subject y message vía RequiredParams.

Para desactivar los tickets de invitado hay una casilla: disable_guest_tickets, en /admin/extension/settings/support. Con ella activa solo abren tickets los clientes autenticados. Si los dejas abiertos, el freno es el limitador de tasa: la política guest_ticket_create es fixed_window con 3 por hora. Advertencia: el limitador resuelve la IP con $this->di['request']->getClientIp(); si FOSSBilling está detrás de un proxy inverso o de Cloudflare y no configuraste security.trusted_proxies en config.php, todos tus visitantes comparten la IP del proxy y el tercer ticket de la hora bloquea al resto del planeta. Para ajustar el límite sin tocar código:

// config.php
'rate_limiter' => [
    'enabled' => true,
    'whitelist_ips' => [],
    'policies' => [
        'guest_ticket_create' => ['policy' => 'fixed_window', 'limit' => 10, 'interval' => '1 hour'],
    ],
],

Respuestas predefinidas, categorías y el selector dentro del ticket

Las canned responses son plantillas de texto que el staff inserta en un clic; dos entidades, CannedResponse.php y CannedResponseCategory.php. Rutas: /admin/support/canned-responses, /admin/support/canned/:id y /admin/support/canned-category/:id. El selector que aparece dentro de la vista del ticket lo renderiza mod_support_canned_selector.html.twig. La API, toda bajo el permiso support:manage_canned:

support/canned_create          support/canned_update      support/canned_get
support/canned_get_list        support/canned_pairs       support/canned_delete
support/canned_category_create support/canned_category_update
support/canned_category_get    support/canned_category_pairs
support/canned_category_delete

Las categorías no son decorativas: el selector agrupa por ellas y con veinte respuestas sin categorizar el desplegable deja de ser útil. Una organización que funciona es una categoría por helpdesk (Facturación, Técnico, Ventas) más una transversal de cierre. Hay un segundo uso menos obvio: el autorespondedor y el mensaje de retraso no se escriben a mano, se seleccionan de esta lista.

Ajustes del módulo: autorespondedor, delay_hours y wait_hours

Ruta /admin/extension/settings/support, plantilla mod_support_settings.html.twig, configuración guardada como mod_support:

ClaveEtiqueta en la UIDefault
autorespond_enableAuto Responderoff
autorespond_message_idAuto Response Message (una canned response)
delay_enableTicket Delayoff
delay_hoursDelay, en horas24
delay_message_idDelay Message (una canned response)
wait_hoursWait Time Between Ticket Submissionsvacío
kb_enableKnowledge Base habilitada
kb_suggestions_ticketSugerencias de KB al abrir un ticketoff
kb_suggestions_contactSugerencias de KB en el formulario de contactooff
kb_article_views_enableContador de vistas de artículos
disable_guest_ticketsGuest Tickets deshabilitadosoff

Uno. autorespond_enable más autorespond_message_id envían la canned response elegida en cuanto se abre el ticket: el “hemos recibido tu consulta”. Dos. delay_enable, delay_hours y delay_message_id envían otra canned response si pasan las horas configuradas sin que nadie del staff conteste; el default de delay_hours es 24. Tres. wait_hours limita la frecuencia con la que un mismo cliente abre tickets nuevos, evaluada por canClientSubmitNewTicket(); es distinto del rate limit de invitados porque aquí el sujeto es la cuenta, no la IP.

No existen horarios de atención en el core. No hay business hours, ni calendario de festivos, ni SLA. Lo más parecido que montas con el core es el autorespondedor más delay_hours. Si alguien te dice que FOSSBilling tiene SLAs, está describiendo otro producto.

Base de conocimiento, sugerencias automáticas y anuncios (News)

La KB vive dentro del módulo Support, con dos entidades: KbArticle.php (constantes ACTIVE = 'active' y DRAFT = 'draft'; campos title, content, slug UNIQUE, views, status) y KbArticleCategory.php. Rutas: /admin/support/kb, /admin/support/kb/article/:id y /admin/support/kb/category/:id. La API bajo el permiso support:manage_kb es kb_article_create, kb_article_update, kb_article_get, kb_article_get_list, kb_article_delete, más kb_category_create, kb_category_update, kb_category_get, kb_category_get_list, kb_category_get_pairs y kb_category_delete.

La parte que reduce tickets de verdad son las sugerencias automáticas: con kb_suggestions_ticket activo, mientras el cliente escribe el asunto se le proponen artículos de la KB, y kb_suggestions_contact hace lo mismo en el formulario de contacto. Ambas están apagadas por defecto; encenderlas es de las pocas acciones de este capítulo con retorno inmediato. kb_article_views_enable activa el contador de la columna views, que sirve para saber qué artículos redactar mejor y para nada más.

Los anuncios son un módulo aparte, News, con rutas /admin/news y /admin/news/post/:id, y API news/get_list, news/get, news/create, news/update, news/delete y news/batch_delete. Es el canal para avisos de mantenimiento programado: se publican en el área de cliente sin generar correo, así que no compiten con la cola de email.

Antispam en los formularios públicos: Turnstile, hCaptcha, honeypot y reCAPTCHA v3

En 0.8.0 el viejo módulo Spamchecker fue sustituido por el módulo Antispam. Si vienes de una instalación anterior, la configuración antispam es de las cosas que hay que revisar tras actualizar: no se migra sola. Lo que ofrece, según la documentación oficial de seguridad —“CAPTCHA, IP blocking, disposable email detection, Stop Forum Spam lookups, and honeypot fields”—, desglosado:

MecanismoDetalle
Cloudflare Turnstilereto sin fricción, desde 0.8.0
hCaptchareto clásico, desde 0.8.0
Honeypotcampo oculto; si se rellena, es un bot
reCAPTCHA v3añadido en 0.8.1, con detección por puntuación
Bloqueo por IPlistas de IPs vetadas
Emails desechablesdetección de dominios de usar y tirar
Stop Forum Spamconsulta a la base externa de spammers conocidos

La configuración está en /admin/extension/settings/antispam y se lee desde Twig como cualquier otra config de módulo con {% set params = admin.extension_config_get({ ext: 'mod_antispam' }) %}. El único endpoint público del módulo es guest/antispam/recaptcha.

En las plantillas del tema el honeypot se inyecta con la función Twig antispam_honeypot(), que devuelve {enabled, field}: enabled dice si está activo y field el nombre del campo oculto que hay que pintar. Si escribes un tema propio (lo verás en el capítulo 15) y olvidas ese campo, el honeypot deja de proteger sin avisar. Gotcha: el reto CAPTCHA no sustituye al rate limit. Los tres controles actúan en momentos distintos: el honeypot descarta bots tontos sin coste, el CAPTCHA frena bots con navegador y guest_ticket_create frena a un humano insistente.

Staff: altas, historial de logins y reset de contraseña

Un miembro del staff es una fila en la tabla admin, mapeada por Staff/Entity/Admin.php. Las rutas del panel:

/admin/staff/manage/:id      /admin/staff/group/:id
/admin/staff/profile         /admin/staff/logins
/admin/staff/login           /admin/staff/passwordreset
/admin/staff/email/:hash     /admin/staff/password_update

La API cubre el CRUD y algo más: staff/get_list, get_pairs, get, create, update, delete, change_password e is_super_administrator; el bloque de grupos con group_create, group_update, group_get, group_get_list, group_get_pairs, group_delete, group_member_add, group_member_remove, group_member_get_list y admin_group_get_list; y la auditoría con login_history_get_list y login_history_get.

staff/login_history_get_list alimenta la página /admin/staff/logins. Es la herramienta de auditoría más básica que tienes y, en ausencia de 2FA, la que más vas a mirar: un login desde una IP o un país inesperado es la primera señal de que una credencial se filtró.

El flujo de recuperación de contraseña pasa por tres endpoints públicos —guest/staff/passwordreset, guest/staff/update_password y guest/staff/login— y está protegido por cuatro políticas de rate limit propias:

PolíticaTipoLímiteIntervalo
staff_password_reset_ipfixed_window51 hour
staff_password_reset_emailfixed_window31 hour
staff_password_reset_confirm_ipfixed_window2060 seconds
staff_password_reset_confirm_post_ipfixed_window2060 seconds

Hay además un cambio de seguridad importante en 0.8.5: el reset de contraseña ahora invalida las sesiones existentes. Antes no lo hacía, y esa era exactamente la vulnerabilidad GHSA-rrp7-fvc6-xxrw (CVE-2026-68544, severidad high, afectaba de 0.5.5 a 0.8.4): quien hubiera secuestrado una sesión la conservaba aunque la víctima cambiara la contraseña. Si estás por debajo de 0.8.5, esa sola razón justifica actualizar.

Para auditar altas y cambios desde otro sistema tienes los eventos onBeforeAdminStaffCreate / onAfterAdminStaffCreate, sus equivalentes de Delete, Update, PasswordChange, ApiKeyChange, ProfileUpdate y ProfilePasswordChange, más onBeforePasswordResetStaff / onAfterPasswordResetStaff, onBeforeAdminLogin / onAfterAdminLogin y onEventAdminLoginFailed. Este último es el que conectas a un webhook si quieres alertas de fuerza bruta en tiempo real.

El modelo de permisos reescrito en 0.8.4: grupos, herencia y super_admin

Aquí va el aviso que ahorra horas: 0.8.4 reescribió los permisos de administrador de un modelo por usuario a un modelo por grupos. No busques la pestaña de permisos dentro de la ficha del administrador; ahora los permisos son del grupo y el administrador pertenece a un grupo. Las tres entidades son Staff/Entity/Admin.php, AdminGroup.php y AdminGroupMember.php, y la tabla admin_group es esta:

ColumnaTipoNotas
namevarchar(255)nombre visible
system_namevarchar(100) UNIQUEidentificador interno; super_admin para superadministradores
parentautorreferenciaherencia de grupos
permissionsJSONmapa { modulo: { access: bool, clave: valor } }
protectedboolgrupos que no se pueden borrar

La constante AdminGroup::SYSTEM_SUPER_ADMIN = 'super_admin' es la que reconoce el chequeo de superadministrador. El instalador mete al primer administrador en admin_group_id = 1 con un INSERT en admin_group_member, y ese grupo es el de superadministradores.

La forma del JSON de permissions es lo que hay que entender para escribirlo a mano o por API. Para un grupo “Soporte nivel 1” que solo ve y contesta tickets:

{
  "support": {
    "access": true,
    "view": true,
    "manage_tickets": true,
    "manage_canned": true
  },
  "client": {
    "access": true,
    "view": true
  }
}

Fíjate en la clave access: es la que abre el módulo entero, y sin ella las claves de dentro no se llegan a evaluar. Los permisos del módulo Support son exactamente seis:

view              View support tickets
manage_tickets    Manage tickets
manage_helpdesk   Manage helpdesks
manage_canned     Manage canned responses
manage_kb         Manage knowledge base
manage_settings   (sin metadatos de presentación)

Los del módulo Client, para contraste, son trece: view, create, edit_profile, impersonate_login, manage_api_keys, change_password, manage_balance, view_login_history, manage_groups, delete, bulk_delete, export y manage_settings. Ese impersonate_login permite entrar al área de cliente como el cliente: si no confías en alguien para eso, no se lo des. Los grupos de clientes, que son otra cosa distinta, están en el capítulo 6.

Creación de un grupo y asignación de un miembro por API, y la consulta para verificar quién está dónde:

curl -u 'admin:TU_API_TOKEN' -H 'Content-Type: application/json' \
  -d '{"name":"Soporte nivel 1"}' \
  https://tu-dominio/api/admin/staff/group_create

curl -u 'admin:TU_API_TOKEN' -H 'Content-Type: application/json' \
  -d '{"admin_group_id":3,"admin_id":4}' \
  https://tu-dominio/api/admin/staff/group_member_add
SELECT a.id, a.email, g.id AS group_id, g.name, g.system_name
FROM admin a
LEFT JOIN admin_group_member m ON m.admin_id = a.id
LEFT JOIN admin_group g ON g.id = m.admin_group_id;

El campo parent habilita herencia: un grupo “Soporte nivel 2” con parent apuntando a “Soporte nivel 1” arranca de los permisos del padre y añade los suyos. Úsalo para no duplicar matrices completas cada vez que creas un rol.

hasPermission() paso a paso y los módulos siempre accesibles

Toda la evaluación pasa por dos métodos de src/modules/Staff/Service.php, con estas firmas exactas:

public function hasPermission(?\Model_Admin $member, string $module, ?string $key = null, mixed $constraint = null): bool
public function checkPermissionsAndThrowException(string $module, ?string $key = null, mixed $constraint = null, ?\Model_Admin $member = null): void

El orden importa, porque los primeros atajos cortocircuitan todo lo demás:

flowchart TD
    A["hasPermission member modulo clave constraint"] --> B{"Es el usuario cron<br/>o el modulo es index dashboard o profile"}
    B -- Si --> OK["Devuelve true"]
    B -- No --> C{"Es super administrador"}
    C -- Si --> OK
    C -- No --> D{"El modulo declara can_always_access"}
    D -- Si --> F{"Se pidio una clave concreta"}
    D -- No --> E{"Existe permissions modulo access igual a true"}
    E -- No --> NO["Devuelve false y error 403"]
    E -- Si --> F
    F -- No --> OK
    F -- Si --> G{"Existe permissions modulo clave"}
    G -- No --> NO
    G -- Si --> H{"Se paso un constraint"}
    H -- Si --> I["Devuelve valor identico a constraint"]
    H -- No --> J["Devuelve el valor convertido a booleano"]

Uno. $alwaysAllowed = ['index', 'dashboard', 'profile']: esos tres módulos siempre devuelven true, y es lo que evita que un administrador sin permisos se quede sin pantalla de inicio ni acceso a su propio perfil. Dos. Si $member->isCron() es verdadero, pasa todos los chequeos; ese es Model_Admin::SYSTEM_CRON, la identidad con la que corren las tareas programadas, y precisamente porque salta todos los permisos su token está prohibido en la API: intentar autenticarse con él devuelve Authentication Failed con código 205. Tres. El mensaje de denegación es literal, You need the "support.manage_helpdesk" permission to perform this action, con código 403; el texto entre comillas es modulo.clave y se busca tal cual en el JSON del grupo. Cuatro. El parámetro constraint permite permisos que no son booleanos: con él la comparación es de identidad estricta contra el valor guardado, y sin él el valor se convierte a booleano.

Hay una tercera puerta, en el enrutado: Box_AppAdmin::checkPermission() exige hasPermission(null, $mod) antes de servir cualquier página del módulo, y el menú lateral solo lista los módulos para los que ese chequeo pasa. Por eso un miembro de staff sin permiso no ve la entrada en el menú, en vez de verla y chocar con un 403.

Declarar permisos en un módulo: getModulePermissions(), can_always_access y has_permission en Twig

Cuando escribas tu propio módulo (tema completo del capítulo 13), la matriz de permisos que aparece en la pantalla del grupo la construye el core llamando a getModulePermissions() en el Service.php del módulo, a través de Extension\Service::getSpecificModulePermissions():

public function getModulePermissions(): array
{
    return [
        'delete_something' => [
            'type' => 'bool',
            'display_name' => __trans('Delete something'),
            'description' => __trans('Allows the staff member to delete "something"'),
        ],
        'can_always_access' => true,   // clave especial: acceso base al modulo sin permiso explicito
        'manage_settings'   => [],     // clave especial: restringe la pagina de ajustes
    ];
}

Las dos claves especiales son las que confunden. can_always_access => true hace que el módulo se salte la comprobación de permissions[modulo]['access']: cualquier miembro de staff entra, y los permisos finos de dentro se siguen evaluando; se usa para módulos utilitarios que no tiene sentido esconder. manage_settings => [] declara la clave que protege la página de ajustes; el array vacío significa “sin metadatos de presentación”. El ejemplo mínimo real del core es src/modules/Cookieconsent/Service.php, que declara exactamente return ['manage_settings' => []];.

Para comprobar el permiso tienes tres formas, de más a menos cómoda:

// 1) Lanza InformationException con codigo 403 y el mensaje estandar
$this->di['mod_service']('Staff')->checkPermissionsAndThrowException('example', 'delete_something');

// 2) Desde una clase Api: AbstractApi ya trae el helper y pasa la identidad
$this->checkPermissions('news', 'manage');

// 3) Manual, cuando necesitas ramificar en vez de abortar
$staff = $this->di['mod_service']('Staff');
if (!$staff->hasPermission(null, 'example', 'delete_something')) {
    throw new \FOSSBilling\InformationException('You do not have permission to perform this action', [], 403);
}

Y en las plantillas Twig del panel, la función global has_permission(string $module, ?string $permission = null): bool:

{% if has_permission('example', 'delete_something') %}
    <button type="button">{{ 'Delete something'|trans }}</button>
{% endif %}

Advertencia: ocultar el botón en Twig no es control de acceso; la API sigue expuesta. Cada método de Api/Admin.php tiene que hacer su propio checkPermissions(), y el has_permission de la plantilla solo evita mostrar una acción que va a fallar.

La ausencia de 2FA en 0.8.5 y las cuatro mitigaciones de infraestructura

Este es un hallazgo negativo verificado, no una opinión:

FOSSBilling 0.8.5 no tiene autenticación de dos factores. Búsqueda en el árbol completo del tag 0.8.5 (1 598 rutas): cero coincidencias de 2fa, twofactor, two_factor, totp u otp. Cero páginas sobre 2FA en los 54 ficheros de documentación oficial. El issue #62 «Multi-factor Authentication» sigue abierto; el PR #2159 «Add Support for 2FA for “Admin”» está cerrado sin fusionar. La página oficial de buenas prácticas, en la sección Admin Access, recomienda solo contraseñas fuertes, restringir el panel por IP y revisar los logs de actividad.

Para un sistema que guarda datos fiscales y credenciales de pasarelas es una carencia relevante, y no tiene solución dentro de la aplicación: hay que ponerla delante. Cuatro opciones, de menos a más completa.

Opción A — Restringir el panel por IP en nginx. Barata y efectiva si tu staff trabaja desde ubicaciones fijas; inútil con equipos remotos y conexiones domésticas dinámicas.

location ^~ /admin {
    allow 203.0.113.0/24;      # oficina
    allow 198.51.100.42;       # VPN
    deny all;

    try_files $uri $uri/ @rewrite;
}

Opción B — Basic Auth adicional delante del panel.

sudo apt -y install apache2-utils
sudo htpasswd -c /etc/nginx/.htpasswd-fossbilling admin
sudo chmod 640 /etc/nginx/.htpasswd-fossbilling
sudo chown root:www-data /etc/nginx/.htpasswd-fossbilling
location ^~ /admin {
    auth_basic "Restringido";
    auth_basic_user_file /etc/nginx/.htpasswd-fossbilling;
    try_files $uri $uri/ @rewrite;
}

Gotcha caro: el .htaccess de FOSSBilling propaga deliberadamente la cabecera Authorization a PHP con RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}], porque la API la necesita para autenticar con API key. Si aplicas Basic Auth sobre rutas que incluyan /api/, rompes a todos tus clientes de API. Aplícalo solo a las rutas de UI del panel, nunca a /api/.

Opción C — Un proxy con SSO y 2FA delante (Authelia, Authentik, Cloudflare Access, oauth2-proxy). Es la solución más completa: el segundo factor lo pone el proxy, con TOTP o llaves de seguridad, y FOSSBilling nunca ve una petición no autenticada. No existe documentación oficial de FOSSBilling sobre integración con ningún proveedor SSO, así que la configuración es puramente del lado del proxy y hay que verificarla tú.

Opción D — Acceso al panel solo por VPN (WireGuard, Tailscale) combinado con la restricción por IP de la opción A. Es la más simple de razonar: el panel deja de existir en internet.

Como complementos —que no sustituyen a lo anterior— cambiar admin_area_prefix en config.php a algo no adivinable reduce el ruido de bots, pero es ofuscación, no seguridad; el rate limit api_login de 10 intentos por hora ya está activo por defecto y es la defensa incorporada contra fuerza bruta; y /admin/staff/logins hay que revisarlo con regularidad. El endurecimiento completo del servidor se trata en el capítulo 16.

Correo saliente: los cuatro transportes y el DSN de Symfony Mailer

FOSSBilling usa Symfony Mailer envuelto en la clase FOSSBilling\Mail, en src/library/FOSSBilling/Mail.php, y nunca envía de forma síncrona: todo pasa por una cola. FOSSBilling\Mail::send() soporta exactamente cuatro transportes:

switch ($this->transport) {
    case 'sendmail':
        $dsn = 'sendmail://default';
        if (!function_exists('proc_open')) {
            throw new InformationException('FOSSBilling requires the proc_open PHP function to be enabled when using the sendmail transport');
        }
        break;
    case 'smtp':
        $dsn = $this->__smtpDsn($options);
        break;
    case 'sendgrid':
        if (empty($options['sendgrid_key'])) {
            throw new InformationException('A SendGrid API key is required to send emails via SendGrid');
        }
        $dsn = 'sendgrid://' . $options['sendgrid_key'] . '@default';
        break;
    case 'custom':
        if (empty($this->dsn)) {
            throw new InformationException("Unable to send email: 'Custom' transport method was selected without a custom DSN");
        }
        $dsn = $this->dsn;
        break;
    default:
        throw new InformationException('Unknown mail transport: :transport', [':transport' => $this->transport]);
}
$transport = Transport::fromDsn($dsn);
$mailer = new Mailer($transport);
$mailer->send($this->email);

El DSN de SMTP se construye en __smtpDsn(), y de ahí salen dos detalles útiles:

$host = urlencode(trim((string) $options['smtp_host']));
$port = Tools::normalizePort($options['smtp_port']);
if ($port === null) throw new InformationException('SMTP port is invalid');

if (!empty($options['smtp_username'])) {
    $username   = urlencode(trim((string) $options['smtp_username']));
    $pass       = urlencode(trim($options['smtp_password'] ?? ''));
    $authString = !empty($pass) ? $username . ':' . $pass : $username;
    return "smtp://$authString@" . $host . ':' . $port;
}
return 'smtp://' . $host . ':' . $port;

Uno. Usuario y contraseña pasan por urlencode(), así que una contraseña con @, : o / funciona sin escapes manuales: pégala tal cual en el formulario. Dos. Si dejas smtp_username vacío, el DSN se genera sin autenticación, que es lo correcto para un relay interno y una fuente de confusión si esperabas un error. Antes de eso, si falta host o puerto lanza SMTP host or port is not configured.

TransporteCuándo usarloRiesgo principal
sendmailservidor propio con MTA local bien configuradorequiere proc_open, deshabilitado en muchos hostings compartidos
smtpcaso general, incluido cualquier proveedor transaccional con SMTPcredenciales en BD; puerto y cifrado mal puestos
sendgridSendGrid, con soporte de primera clase vía sendgrid://KEY@defaultdependencia de un solo proveedor
customDSN arbitrario de Symfony Mailerel bridge del proveedor tiene que estar en vendor/

Mailgun, Postmark, Amazon SES, Brevo o Resend se configuran con custom y la sintaxis de Symfony Mailer:

smtp://usuario:[email protected]:587
smtp://usuario:[email protected]:465?encryption=ssl
ses+smtp://ACCESS_KEY:SECRET_KEY@default?region=eu-west-1
mailgun+https://KEY:DOMAIN@default
postmark+api://TOKEN@default

Advertencia: los transportes con esquema propio (ses+smtp, mailgun+https, postmark+api) exigen que el paquete Composer del bridge correspondiente esté instalado en el vendor/ de FOSSBilling. El único bridge que el código garantiza es el de SendGrid; ante la duda, un DSN SMTP puro siempre funciona y todos esos proveedores ofrecen endpoint SMTP.

Y el punto que más tiempo hace perder: la configuración de correo no vive en config.php. src/config-sample.php no contiene ninguna clave de email; todo está en la configuración del módulo, en base de datos, y se lee con $this->di['mod']('email')->getConfig(). La UI es Configuración → Email settings, con radios cuyo name="mailer" toma los valores sendmail, smtp, sendgrid o custom.

La cola mod_email_queue: queue_once, tries, cancel_after y time_limit

La cola es la tabla mod_email_queue, mapeada por Model_ModEmailQueue / la entidad QueuedEmail. El historial de correos enviados va a activity_client_email.

flowchart LR
    A["Evento del sistema<br/>factura pedido o ticket"] --> B["Fila en mod_email_queue"]
    B --> C["cron ejecuta email_batch_sendmail"]
    C --> D["Email Service _sendFromQueue"]
    D --> E["FOSSBilling Mail send"]
    E --> F{"transporte"}
    F -->|sendmail| G["sendmail://default"]
    F -->|smtp| H["smtp://user:pass@host:port"]
    F -->|sendgrid| I["sendgrid://KEY@default"]
    F -->|custom| J["DSN libre de Symfony"]
    D -->|error| K["baja prioridad<br/>status failed y tries mas uno"]
    K -->|tries mayor que cancel_after| L["se descarta y queda en Activity"]
    K -->|si no| B

Claves de configuración del módulo mod_email, con sus defaults reales:

ClaveTipoDefaultUso
mailerradiosendmailtransporte; $settings['mailer'] ?? 'sendmail'
smtp_host / smtp_porttextsolo con mailer=smtp; el puerto pasa por Tools::normalizePort()
smtp_username / smtp_passwordtextopcionales; sin usuario, DSN sin autenticación
sendgrid_keytextsolo con mailer=sendgrid
custom_dsntextsolo con mailer=custom
from_email / from_nameidentidad From, separada desde 0.8.0
reply_toemailsi es inválido se omite con un warning en el canal email
signaturetextfirma global
log_enabledboolfalseguarda copia en el historial al enviar
cancel_afterint5intentos máximos antes de descartar
time_limitint (minutos)5tope del lote; el código hace ($settings['time_limit'] ?? 5) * 60
queue_onceint0cuántos correos procesa cada ejecución de cron

tries es el contador de intentos de cada fila de la cola, y es lo que se compara contra cancel_after.

El error número uno de toda instalación nueva: queue_once = 0. Email\Service::batchSend() hace literalmente $sendPerCron = $settings['queue_once'] ?? 0;. Con cero el lote no procesa nada, la cola crece y no sale ni un correo: sin mensaje de error y sin aviso en el panel. Ponlo en algo razonable —50 funciona bien con cron cada 5 minutos— y comprueba que la cola baja.

El recorrido de Email\Service::_sendFromQueue(QueuedEmail $queue, bool $throw_exceptions = false), en orden: si la extensión demo está activa no envía nada y devuelve false; marca status = 'sending' y hace flush(); añade PHP_EOL al contenido; construye FOSSBilling\Mail($sender, $recipient, $subject, $content, $transport, $settings['custom_dsn'] ?? null); adjunta el fichero opcional de la fila (attachment_content, attachment_name, attachment_mime, con attachment y application/octet-stream por defecto); aplica reply_to si es válido; y entonces corta si el entorno no es producción:

if (!Environment::isProduction()) {
    $this->di['logger']->setChannel('email')->info('Skip email sending. Application ENV: ' . Environment::getCurrentEnvironment());
    return true;
}

Si pasa ese filtro, llama a $mail->send($settings) con el array completo de ajustes viajando como $options al transporte; si log_enabled está activo, registra en Activity y elimina la fila. En error baja la prioridad, marca status = failed y suma uno a tries; si tries > cancel_after (por defecto 5) lo registra en Activity —para poder reenviarlo a mano— y lo descarta de la cola. Los mensajes de error de más de 350 caracteres se truncan con el aviso “Error message truncated due to length, please check the error log for the complete message: ”.

Ese corte por entorno es la segunda causa número uno de “no me llegan los correos”, y es específica de staging: con APP_ENV=dev no sale nada y el único rastro está en data/log/email/, en la línea Skip email sending. Application ENV: dev.

El vaciado lo dispara la tarea email_batch_sendmail, que es la última de la lista de Cron\Service::runCrons(). Delante van, entre otras, support_batch_ticket_auto_close, la generación de facturas y los recordatorios. Es un orden sensato: primero se genera todo lo que produce correo, luego se vacía la cola en la misma pasada.

Las 46 plantillas del core, el editor y las variables cifradas del último envío

Cada plantilla por defecto es un fichero en src/modules/{Modulo}/templates/email/{code}.html.twig con dos bloques, {% block subject %} y {% block content %}. Email\Service::_getDefaults() extrae ambos con expresión regular y solo marca la plantilla como enabled = 1 si consigue extraer el bloque content: una plantilla sin {% block content %} queda deshabilitada en silencio. El código sigue el patrón mod_{modulo}_{accion}; el módulo se deduce con preg_match('/mod_([a-zA-Z0-9]+)_([a-zA-Z0-9]+)/i', $code) y, si no casa, cae en la categoría custom. Las 46 del core en 0.8.5:

mod_client_confirm                       mod_servicedomain_activated
mod_client_password_reset_information    mod_servicedomain_renewed
mod_client_password_reset_request        mod_servicedomain_suspended
mod_client_signup                        mod_servicedomain_unsuspended
mod_client_signup_admin                  mod_servicedownloadable_activated
mod_email_test                           mod_servicehosting_activated
mod_invoice_created                      mod_servicehosting_canceled
mod_invoice_due_after                    mod_servicehosting_renewed
mod_invoice_paid                         mod_servicehosting_suspended
mod_invoice_payment_reminder             mod_servicehosting_unsuspended
mod_serviceapikey_activated              mod_servicelicense_activated
mod_serviceapikey_canceled               mod_servicelicense_canceled
mod_serviceapikey_renewed                mod_servicelicense_renewed
mod_serviceapikey_suspended              mod_servicelicense_suspended
mod_serviceapikey_unsuspended            mod_servicelicense_unsuspended
mod_servicecustom_activated              mod_staff_client_order
mod_servicecustom_canceled               mod_staff_client_signup
mod_servicecustom_renewed                mod_staff_password_reset_approve
mod_servicecustom_suspended              mod_staff_password_reset_request
mod_servicecustom_unsuspended            mod_staff_ticket_close
mod_support_helpdesk_ticket_open         mod_staff_ticket_open
mod_support_ticket_open                  mod_staff_ticket_reply
mod_support_ticket_staff_close
mod_support_ticket_staff_open
mod_support_ticket_staff_reply

El disparo de las plantillas de servicio es genérico: Order\Service::onAfterAdminOrderActivate() compone el código con sprintf('mod_service%s_activated', $orderArr['service_type']). De ahí sale una consecuencia directa: servicedomain no tiene plantilla _canceled y servicedownloadable solo tiene _activated. Si necesitas avisar de una cancelación de dominio hay que crear la plantilla como custom con ese código exacto; si no existe, el envío simplemente no ocurre.

Las de soporte son las que más vas a tocar: mod_support_ticket_open (aviso al cliente), mod_support_helpdesk_ticket_open (aviso al departamento) y las ternas mod_support_ticket_staff_open / _reply / _close y mod_staff_ticket_open / _reply / _close.

La gestión desde el panel está en /admin/email/templates, /admin/email/template/:id, /admin/email/history y /admin/email/:id, con tres botones que conviene conocer. Generate templates (API email_batch_template_generate) recrea las plantillas que falten y es el procedimiento oficial de recuperación cuando alguien borró plantillas del core. Reset (email_template_reset) devuelve una plantilla built-in al contenido de su fichero vía resetBuiltinTemplate(). Validate all (email_template_validate_all, desde 0.8.2) valida la sintaxis Twig de todas y guarda los errores en last_error y error_checked_at de cada plantilla, con getBrokenTemplateCount() contándolos para el panel. Este último importa más de lo que parece: Twig corre con strict_variables activo, así que una variable indefinida rompe el render y el correo no sale; hubo una oleada de esos fallos corregida en 0.8.3.

Sobre las variables, el texto que FOSSBilling genera automáticamente para una plantilla nueva (_getVarsString()) es la fuente autorizada:

{{ FOSSBillingVersion }}
{{ guest.system_company.name }}
{{ guest.system_email.signature }}
{{ now|date('Y-m-d') }}
{% apply markdown_to_html %} ... {% endapply %}

Los filtros usados en las plantillas reales del core son |format_date, |format_currency(codigo), |period_title, |trans, |url(...), |default(...), |e y markdown_to_html; hay una variable global default_currency y el helper de URL se usa como {{ 'login'|url(query: {'email': c.email}, area: 'client') }}. Los contextos habituales son c (cliente), invoice, order, service y, para soporte, ticket, message y staff.

El truco que ahorra prueba y error: las variables reales del último envío se guardan cifradas en la columna email_template.vars, con crypt->encrypt(json_encode($vars), Config::getProperty('info.salt')), y se muestran descifradas en la pestaña Variables del editor. El consejo oficial es exactamente ese: «Send a test email to see which variables are available for that template.» Para previsualizar sin haber enviado nunca nada, Email\Service::getTemplateVarDefaults($actionCode) provee objetos staff, client, ticket y message de muestra, y las mod_invoice_* reciben un invoice.total = 0 con la moneda por defecto.

Checklist de diagnóstico: por qué no sale ningún correo

Ataca las causas en este orden, que está ordenado por frecuencia real y no por elegancia:

flowchart TD
    A["No llega ningun correo"] --> B{"Environment isProduction es true"}
    B -- No --> B1["Log dice Skip email sending Application ENV<br/>Quitar APP_ENV dev"]
    B -- Si --> C{"La extension demo esta activa"}
    C -- Si --> C1["Desactivar la extension demo"]
    C -- No --> D{"queue_once es mayor que cero"}
    D -- No --> D1["Ponerlo en 50 en Email settings"]
    D -- Si --> E{"El cron corre de verdad"}
    E -- No --> E1["Revisar last_cron_exec y data/log/cron/"]
    E -- Si --> F["Ejecutar email_send_test"]
    F -->|falla| G{"El transporte es sendmail"}
    G -- Si --> G1["Falta proc_open<br/>Cambiar a smtp"]
    G -- No --> G2["Revisar credenciales y puerto SMTP"]
    F -->|funciona| H["Ejecutar email_template_validate_all"]
    H -->|hay errores| H1["Corregir Twig o pulsar Reset"]
    H -->|todo ok| I["Revisar data/log/email/ y mod_email_queue"]

Traducido a comandos:

# 1. Entorno: no debe haber APP_ENV=dev en el servicio PHP-FPM ni en el contenedor
grep -R "APP_ENV" /etc/php/*/fpm/pool.d/ 2>/dev/null

# 2. Estado real de la cola
mariadb fossbilling -e "SELECT status, COUNT(*) FROM mod_email_queue GROUP BY status;"
mariadb fossbilling -e "SELECT id, recipient, subject, status, tries FROM mod_email_queue ORDER BY id DESC LIMIT 20;"

# 3. Cuando corrio el cron por ultima vez, y su log
mariadb fossbilling -e "SELECT param, value FROM setting WHERE param='last_cron_exec';"
tail -100 /var/www/fossbilling/data/log/cron/cron-$(date +%Y-%m-%d).log

# 4. Log del canal de email y pasada manual de cron
tail -100 /var/www/fossbilling/data/log/email/email-$(date +%Y-%m-%d).log
sudo -u www-data php /var/www/fossbilling/cron.php

Prueba directa del transporte, sin depender de la cola ni del cron, y validación masiva de plantillas:

curl -u 'admin:TU_API_TOKEN' -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]"}' \
  https://tu-dominio/api/admin/email/send_test

curl -u 'admin:TU_API_TOKEN' -X POST \
  https://tu-dominio/api/admin/email/template_validate_all

Los endpoints de correo que más se usan en diagnóstico:

EndpointPara qué
email/send_testprobar el transporte de punta a punta
email/template_validate_allvalidar el Twig de las 46 plantillas
email/batch_template_generaterecrear plantillas del core que falten
email/template_resetdevolver una plantilla a su fichero original
email/get_queueinspeccionar lo que está pendiente
email/batch_sendmailvaciar la cola a mano; es lo que llama el cron
email/email_resendreenviar un correo del historial
email/template_renderprevisualizar sin enviar
email/template_sendenviar una plantilla concreta y poblar la pestaña Variables

Recuerda que log_enabled está apagado por defecto: sin él, email/email_get_list está vacío y no puedes reenviar nada. Enciéndelo antes de necesitarlo, no después.

Massmailer, historial de correos y el registro de actividad

El módulo Massmailer (/admin/massmailer y /admin/massmailer/message/:id) manda campañas a segmentos de clientes. La entidad MassmailerMessage tiene estados STATUS_DRAFT y STATUS_SENT, y campos from_email, from_name, subject, content, filter (JSON), status y sent_at. El filtro admite exactamente cuatro claves, y cualquier otra hace fallar la validación en handleInvalidFilter: client_status (sobre client.status), client_groups (sobre client.client_group_id), has_order (sobre client_order.product_id) y has_order_with_status (sobre client_order.status). La API es massmailer/get_list, get, create, update, copy, delete, receivers, preview, send, send_test y get_test_client.

Gotcha: el envío de una campaña entra por la misma cola que el correo transaccional. Una campaña a 5 000 clientes con queue_once = 50 y cron cada 5 minutos tarda más de ocho horas en salir, y mientras tanto tus facturas y respuestas de tickets van detrás en la fila. Antes de una campaña grande sube queue_once temporalmente y vigila mod_email_queue; y usa siempre massmailer/receivers y massmailer/preview antes de send.

El registro de actividad está en /admin/activity (también en /admin/system/activity), con las tablas activity_admin_history, activity_client_history, activity_client_email y activity_system. La API es activity/log_get_list (permiso activity:view), activity/log (permiso activity:manage) y activity/log_email. Un detalle que despista: log_get_list filtra por defecto min_priority = 6 en niveles Monolog, así que los eventos de baja prioridad no aparecen hasta que bajas ese umbral; cada fila viene enriquecida con los sub-objetos staff y client.

Los logs en disco son 11 canales bajo data/log/, con rotación diaria automática y retención de 90 días (RotatingFileHandler con maxFiles = 90): activity, application, cron, database, license, mail, event, routing, billing, security y email. Para este capítulo importan email y cron. Ojo con la duplicidad: existen los canales mail y email, y el que usa Email\Service en el corte por entorno es email.

Errores comunes y diagnóstico

SíntomaCausa probableCómo diagnosticarlo y arreglarlo
No sale ningún correo, la cola crecequeue_once = 0 en mod_emailEmail settings → queue_once a 50; SELECT COUNT(*) FROM mod_email_queue
No sale ningún correo y no hay erroresEnvironment::isProduction() es falsoBuscar Skip email sending. Application ENV: en data/log/email/; quitar APP_ENV=dev
No sale ningún correo en una demoLa extensión demo está activa_sendFromQueue() devuelve false en el primer paso; desactivarla
FOSSBilling requires the proc_open PHP function to be enabled when using the sendmail transportHosting con proc_open deshabilitadoCambiar el transporte a smtp
SMTP host or port is not configuredFalta smtp_host o smtp_portRellenar ambos
SMTP port is invalidPuerto no numérico o fuera de rangoCorregirlo; pasa por Tools::normalizePort()
A SendGrid API key is required to send emails via SendGridmailer=sendgrid sin sendgrid_keyPegar la API key
Unable to send email: 'Custom' transport method was selected without a custom DSNmailer=custom con custom_dsn vacíoEscribir un DSN válido de Symfony Mailer
Unknown mail transport: :transportValor de mailer fuera de los cuatro soportadosSolo sendmail, smtp, sendgrid, custom
Un correo concreto nunca llega, los demás síPlantilla Twig rota; strict_variables aborta el renderemail_template_validate_all; revisar last_error
Se borraron plantillas del coreBorrado accidentalBotón Generate templates (email_batch_template_generate)
El correo desapareció de la cola sin enviarsetries > cancel_after (default 5)Buscarlo en Activity y reenviar con email_email_resend; requiere log_enabled
El historial de correos está vacíolog_enabled = false por defectoActivarlo en Email settings
Los tickets no se auto-cierranEstán en open, no en on_holdSolo on_hold entra en la consulta de auto-cierre
Los tickets on_hold tampoco se cierranclose_after es NULL en el helpdeskSELECT id,name,close_after FROM support_helpdesk
Los tickets se cierran demasiado prontoclose_after interpretado como díasEstá en horas: una semana son 168
Ni auto-cierre ni envíosEl cron no corre o está pausadolast_cron_exec; si hay actualización pendiente, /admin/system/update/finalize
Un visitante legítimo no puede abrir ticketguest_ticket_create agotado: 3 por horaFalta security.trusted_proxies, o subir el límite en rate_limiter.policies
El formulario público recibe spamAntispam sin configurar tras actualizar desde 0.7.x/admin/extension/settings/antispam; Spamchecker ya no existe
You need the "x.y" permission to perform this actionFalta la clave en el JSON del grupoEl texto entre comillas es modulo.clave; añadirla en Staff → Groups
Un miembro de staff no ve el módulo en el menúBox_AppAdmin::checkPermission() exige hasPermission(null, $mod)Poner access: true para ese módulo en el grupo
No encuentras la pestaña de permisos del administradorModelo por usuario eliminado en 0.8.4Los permisos son del grupo, no del usuario
Authentication Failed código 205 con un tokenEs el token del admin de sistema cronUsar el token de un administrador real
Un botón oculto en Twig sigue siendo ejecutable por APIhas_permission solo oculta, no protegeAñadir checkPermissions() en el método de Api/Admin.php
Un listener de tickets de invitado dejó de dispararseEventos *GuestPublicTicket* eliminados en 0.8.3Migrar a onBeforeClientOpenTicket y familia
Una campaña masiva retrasa las facturasMassmailer comparte cola con el correo transaccionalSubir queue_once durante la campaña

Lo que queda operativo

Con este capítulo tienes cerrado el lado humano y comunicativo del sistema: departamentos con auto-cierre que funciona porque entendiste que solo aplica a on_hold y que close_after está en horas; respuestas predefinidas conectadas al autorespondedor y al aviso de demora; una base de conocimiento que sugiere artículos antes de que se abra el ticket; formularios públicos con Turnstile o honeypot y el rate limit calibrado a tu topología de red; grupos de staff con permisos mínimos y la matriz JSON bajo control; el panel detrás de una barrera de infraestructura que suple la 2FA que el core no trae; y correo saliente que sale, con un diagnóstico que va del entorno a la plantilla en el orden en que las cosas fallan de verdad.

Queda un cabo suelto grande: hasta ahora el dinero entra a mano. En el capítulo 10 se conecta Stripe y PayPal, se recorre el endpoint IPN real de src/ipn.php, la verificación de firma HMAC, las tres capas de deduplicación de transacciones y el esqueleto completo de un Payment_Adapter propio para cuando tu proveedor de pagos local no está en el directorio de extensiones.