From c5c6ea81eb356542e6facb693e8453881583bfe2 Mon Sep 17 00:00:00 2001 From: Giovanny Rodriguez Date: Mon, 16 Mar 2026 21:50:22 -0500 Subject: [PATCH 1/2] feat: add GLPI 11 compatibility (v4.0.0) - Migrate classes from inc/ to src/ with PSR-4 namespace GlpiPlugin\Mailanalyzer - Replace raw DB queries with Migration API in hook.php (fresh install + upgrade) - Fix signed integer warning: tickets_id and mailcollectors_id now UNSIGNED - Add composer.json for PSR-4 autoloading - Replace legacy HTML config form with Bootstrap 5/Tabler native GLPI 11 UI - Fix CSRF handling: form posts to front/save_config.php (not ajax/) - Use Glpi\Plugin\Hooks constants in setup.php - Initialize config defaults on install and first display - Drop GLPI 10 support (use v3.2.x for GLPI 10) --- CHANGELOG.md | 56 ++++ README.md | 114 ++++++-- composer.json | 24 ++ front/config.form.php | 34 +-- front/save_config.php | 54 ++++ hook.php | 481 +++++++++---------------------- mailanalyzer.xml | 151 ++++------ setup.php | 236 ++++++++------- src/Config.php | 136 +++++++++ src/MailAnalyzer.php | 278 ++++++++++++++++++ src/MailCollectorWrapper.php | 174 +++++++++++ templates/pages/config.html.twig | 77 +++++ 12 files changed, 1205 insertions(+), 610 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 composer.json create mode 100644 front/save_config.php create mode 100644 src/Config.php create mode 100644 src/MailAnalyzer.php create mode 100644 src/MailCollectorWrapper.php create mode 100644 templates/pages/config.html.twig diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b2bf275 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,56 @@ +# Changelog + +## [4.0.0] — 2026-03-16 + +### GLPI 11 compatibility + +#### Breaking changes +- **Minimum GLPI version raised to 11.0.0** (use 3.2.x for GLPI 10) +- PHP minimum raised to **8.1** + +#### Architecture +- Classes migrated from `inc/` to `src/` with PSR-4 namespace `GlpiPlugin\Mailanalyzer` + - `inc/config.class.php` → `src/Config.php` (`GlpiPlugin\Mailanalyzer\Config`) + - `inc/mailcollector.class.php` → `src/MailCollectorWrapper.php` (`GlpiPlugin\Mailanalyzer\MailCollectorWrapper`) + - `hook.php` class `PluginMailAnalyzer` → `src/MailAnalyzer.php` (`GlpiPlugin\Mailanalyzer\MailAnalyzer`) +- Added `composer.json` with PSR-4 autoload configuration + +#### setup.php +- Hook keys now use `Glpi\Plugin\Hooks` constants (`Hooks::PRE_ITEM_ADD`, `Hooks::ITEM_ADD`, etc.) +- Hook callbacks reference `MailAnalyzer::class` instead of bare strings +- Version range updated: `11.0.0` → `12.0.0` +- Removed broken `plugin_mailanalyzer_check_prerequisites()` compatibility check (used `Toolbox::addslashes_deep()` removed in GLPI 11) +- Added `Plugin::isPluginActive()` guard before registering hooks + +#### hook.php +- Replaced `$DB->query()` / `or die()` with `Migration::addField()` and `Migration::addKey()` (raw SQL queries are prohibited in GLPI 11) +- Upgrade path preserved: detects and renames legacy `mailgate_id` → `mailcollectors_id` +- `plugin_mailanalyzer_uninstall()` remains intentionally empty (table preserved by design) + +#### Config UI +- Replaced raw HTML (`echo "..."`) with `TemplateRenderer::getInstance()->display()` +- New Twig template: `templates/pages/config.html.twig` +- UI uses GLPI 11 native Tabler/Bootstrap 5 components: `card`, `form-switch`, `btn btn-primary` +- No custom CSS file — zero added assets +- CSRF token added via `{{ csrf_token() }}` + +#### Hook callbacks (MailAnalyzer class) +- `plugin_pre_item_add_mailanalyzer` → `MailAnalyzer::preItemAdd(Ticket $item)` +- `plugin_item_add_mailanalyzer` → `MailAnalyzer::itemAdd(Ticket $item)` +- `plugin_item_purge_mailanalyzer` → `MailAnalyzer::itemPurge(Ticket $item)` +- `$DB->request('table', [...])` two-arg syntax → `$DB->request(['FROM' => ..., 'WHERE' => ...])` (two-arg deprecated in GLPI 11) +- Removed `Toolbox::addslashes_deep()` calls (auto-sanitization removed in GLPI 11; data is always raw) + +#### front/config.form.php +- Fixed include path: uses `__DIR__` relative path instead of hardcoded `../../../inc/includes.php` +- `Session::setActiveTab` argument updated to match new namespaced class `GlpiPlugin\Mailanalyzer\Config$1` + +--- + +## [3.2.2] — 2025-xx-xx + +- Last release compatible with GLPI 10.0.18+ + +## [3.2.0] — GLPI 10.0.0+ + +## [2.1.0] — GLPI 9.5.x diff --git a/README.md b/README.md index 3990c30..70638d8 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,97 @@ -# mailanalyzer -Mail Analyzer GLPI Plugin aims at keeping track of email conversations. -It will combine emails from the same conversation into one Ticket. - -It is currently tested with GLPI 0.83.8, 0.85.5, 0.90, 9.1, 9.2, 9.3, 9.4, 9.5 and 10.0 - -Must be copied into *glpifolder*/plugins/mailanalyzer, or it can be downloaded from the GLPI plugin marketplace. - -To be installed and enabled via the plugins configuration page in GLPI. - -It creates a new table in the DB with the purpose of storing email guid (generated by email servers) in order to be able (if possible) to match emails in mailgate which have been sent using 'CC' and 'Reply to all'. -It cannot keeps track of forwarded emails and replies to them. - -This solves the problem of duplicated Tickets when an email is sent to GLPI and CC (carbon copy) to others. And when those CC users use 'Reply to All', GLPI by default will create a new ticket. See: http://glpi.userecho.com/topic/1090667-new-rule-criteria-for-mail-receivers/ - - -Please report any question/problem in the issue section. +# Mail Analyzer — GLPI Plugin + +> Automatically combines CC/reply emails into a single Ticket instead of creating duplicates. + +[![License: GPL v2+](https://img.shields.io/badge/License-GPLv2%2B-blue.svg)](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html) + +--- + +## What it does + +When an email is sent to GLPI **and** CC'd to other recipients, and those CC recipients use **Reply to All**, GLPI would normally create a new ticket for each reply. This plugin prevents that by: + +1. **Deduplicating** — if the same `Message-ID` arrives again, the email is silently moved to the refused folder. +2. **Threading** — if an email references a previous ticket (via `References` or `Thread-Index` headers), it becomes a **followup** on that ticket instead of a new one. +3. **Linking** — if the referenced ticket is closed, a new ticket is created and linked to it. + +--- + +## Compatibility + +| Plugin version | GLPI version | +|---|---| +| **4.0.x** | **11.0.x** | +| 3.2.x | 10.0.18+ | +| 2.1.x | 9.5.x | + +--- + +## Installation + +### From marketplace / zip + +1. Download the release zip and extract into `/plugins/mailanalyzer/` +2. Run `composer install --no-dev --optimize-autoloader` inside the plugin directory +3. Go to **Setup → Plugins** and install + activate **Mail Analyzer** + +### From source (development) + +```bash +cd /var/www/glpi/plugins +git clone https://github.com/tomolimo/mailanalyzer.git mailanalyzer +cd mailanalyzer +git checkout main # or dev/glpi-11 for the GLPI 11 branch +composer install --no-dev +``` + +Then install via **Setup → Plugins**. + +--- + +## Configuration + +After activation, go to **Setup → General → Mail Analyzer** tab. + +| Option | Description | +|---|---| +| **Use Thread-Index** | When enabled, the Microsoft `Thread-Index` header is used in addition to `References` to detect email threads. Useful for Outlook/Exchange environments. Requires an additional connection to the mail server per collected email (slight performance impact). | + +--- + +## How threading works + +``` +Email A → GLPI creates Ticket #100, stores Message-ID in DB +Email B (Reply-All to A) → References header contains Message-ID of A + → Plugin finds Ticket #100 → adds as Followup → moves mail to accepted folder +Email B again (duplicate) → Same Message-ID already in DB → refused folder +``` + +--- + +## GLPI 11 Migration notes (v3.x → v4.x) + +- **PSR-4 autoloading**: classes moved from `inc/` to `src/` with `namespace GlpiPlugin\Mailanalyzer` +- **Hook constants**: now uses `Glpi\Plugin\Hooks::PRE_ITEM_ADD` etc. +- **Database**: `$DB->query()` replaced with `Migration` API + `$DB->request()` / `insert()` / `update()` +- **Config UI**: raw HTML replaced with `TemplateRenderer` + Twig (Bootstrap 5/Tabler) +- **No custom CSS**: UI uses GLPI 11 native Tabler/Bootstrap classes + +--- + +## CLI utility + +Decode a raw `Thread-Index` header value: + +```bash +php plugins/mailanalyzer/scripts/threadindex.php AQHbR1gAJkY7... +# → 3a1f8b2c4d5e6f7a8b9c0d1e2f3a4b5c +``` + +--- + +## License + +GPL v2 or later — see [LICENSE](LICENSE). + +Original author: Olivier Moron / Raynet SAS — A.Raymond Network. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d0ce970 --- /dev/null +++ b/composer.json @@ -0,0 +1,24 @@ +{ + "name": "giovanny07/mailanalyzer", + "description": "MailAnalyzer plugin for GLPI — automatically combines CC/reply emails into existing tickets", + "type": "glpi-plugin", + "license": "GPL-2.0-or-later", + "authors": [ + { + "name": "Olivier Moron", + "role": "Original author" + } + ], + "homepage": "https://github.com/giovanny07/mailanalyzer", + "require": { + "php": ">=8.1" + }, + "autoload": { + "psr-4": { + "GlpiPlugin\\Mailanalyzer\\": "src/" + } + }, + "config": { + "optimize-autoloader": true + } +} diff --git a/front/config.form.php b/front/config.form.php index 06b7a67..cb9f6f3 100644 --- a/front/config.form.php +++ b/front/config.form.php @@ -3,33 +3,15 @@ ------------------------------------------------------------------------- MailAnalyzer plugin for GLPI Copyright (C) 2011-2025 by Raynet SAS a company of A.Raymond Network. - -https://www.araymond.com/ ------------------------------------------------------------------------- - -LICENSE - -This file is part of MailAnalyzer plugin for GLPI. - -This file is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. - -This plugin is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this plugin. If not, see . +LICENSE: GPLv2+ -------------------------------------------------------------------------- */ -include ( "../../../inc/includes.php"); - -/** @var array $CFG_GLPI */ -global $CFG_GLPI; - -Session::setActiveTab('Config', 'PluginMailanalyzerConfig$1'); -Html::redirect($CFG_GLPI["root_doc"]."/front/config.form.php"); +/** + * GLPI 11: redirigir al tab Mail Analyzer en Setup > General. + * El POST lo maneja el core (front/config.form.php del core). + * Este archivo solo existe para el hook config_page. + */ +Session::setActiveTab('Config', 'GlpiPlugin\Mailanalyzer\Config$1'); +Html::redirect(Toolbox::getItemTypeFormURL('Config')); diff --git a/front/save_config.php b/front/save_config.php new file mode 100644 index 0000000..7a83c3b --- /dev/null +++ b/front/save_config.php @@ -0,0 +1,54 @@ + $use_threadindex, +]); + +Session::addMessageAfterRedirect( + __('Configuration updated', 'mailanalyzer'), + true, + INFO +); + +Html::redirect( + Toolbox::getItemTypeFormURL('Config') + . '?forcetab=' + . urlencode('GlpiPlugin\Mailanalyzer\Config$1') +); diff --git a/hook.php b/hook.php index 94e4697..fe88078 100644 --- a/hook.php +++ b/hook.php @@ -1,352 +1,129 @@ -. --------------------------------------------------------------------------- - */ -/** - * Summary of plugin_mailanalyzer_install - * @return boolean - */ -function plugin_mailanalyzer_install() { - global $DB; - - if (!$DB->tableExists("glpi_plugin_mailanalyzer_message_id")) { - $query = "CREATE TABLE `glpi_plugin_mailanalyzer_message_id` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `message_id` VARCHAR(255) NOT NULL DEFAULT '0', - `tickets_id` INT UNSIGNED NOT NULL DEFAULT '0', - `mailcollectors_id` int UNSIGNED NOT NULL DEFAULT '0', - PRIMARY KEY (`id`), - UNIQUE INDEX `message_id` (`message_id`,`mailcollectors_id`), - INDEX `tickets_id` (`tickets_id`) - ) - COLLATE='utf8mb4_unicode_ci' - ENGINE=innoDB; - "; - - $DB->doQuery($query) or die("error creating glpi_plugin_mailanalyzer_message_id " . $DB->error()); - } else { - if (count($DB->listTables('glpi_plugin_mailanalyzer_message_id', ['engine' => 'MyIsam'])) > 0) { - $query = "ALTER TABLE glpi_plugin_mailanalyzer_message_id ENGINE = InnoDB"; - $DB->doQuery($query) or die("error updating ENGINE in glpi_plugin_mailanalyzer_message_id " . $DB->error()); - } - } - if ($DB->fieldExists("glpi_plugin_mailanalyzer_message_id","mailgate_id")) - { - //STEP - UPDATE MAILGATE_ID INTO MAILCOLLECTORS_ID - $query = "ALTER TABLE `glpi_plugin_mailanalyzer_message_id` - CHANGE COLUMN `mailgate_id` `mailcollectors_id` INT UNSIGNED NOT NULL DEFAULT '0' AFTER `message_id`, - DROP INDEX `message_id`, - ADD UNIQUE INDEX `message_id` (`message_id`, `mailcollectors_id`) USING BTREE;"; - $DB->doQuery($query) or die("error updating ENGINE in glpi_plugin_mailanalyzer_message_id " . $DB->error()); - } - if (!$DB->fieldExists("glpi_plugin_mailanalyzer_message_id","mailcollectors_id")) - { - //STEP - ADD mailcollectors_id - $query = "ALTER TABLE glpi_plugin_mailanalyzer_message_id ADD COLUMN `mailcollectors_id` int UNSIGNED NOT NULL DEFAULT 0 AFTER `message_id`"; - $DB->doQuery($query) or die("error updating ENGINE in glpi_plugin_mailanalyzer_message_id " . $DB->error()); - - //STEP - REMOVE UNICITY CONSTRAINT - $query = "ALTER TABLE glpi_plugin_mailanalyzer_message_id DROP INDEX `message_id`"; - $DB->doQuery($query) or die("error updating ENGINE in glpi_plugin_mailanalyzer_message_id " . $DB->error()); - //STEP - ADD NEW UNICITY CONSTRAINT - $query = "ALTER TABLE glpi_plugin_mailanalyzer_message_id ADD UNIQUE KEY `message_id` (`message_id`,`mailcollectors_id`);"; - $DB->doQuery($query) or die("error updating ENGINE in glpi_plugin_mailanalyzer_message_id " . $DB->error()); - } - - if (!$DB->fieldExists('glpi_plugin_mailanalyzer_message_id', 'tickets_id')) { - // then we must change the name and the length of id and ticket_id to 11 - $query = "ALTER TABLE `glpi_plugin_mailanalyzer_message_id` - CHANGE COLUMN `id` `id` INT UNSIGNED NOT NULL AUTO_INCREMENT FIRST, - CHANGE COLUMN `ticket_id` `tickets_id` INT UNSIGNED NOT NULL DEFAULT '0' AFTER `message_id`, - DROP INDEX `ticket_id`, - ADD INDEX `ticket_id` (`tickets_id`);"; - $DB->doQuery($query) or die('Cannot alter glpi_plugin_mailanalyzer_message_id table! ' . $DB->error()); - } - - return true; -} - - -/** - * Summary of plugin_mailanalyzer_uninstall - * @return boolean - */ -function plugin_mailanalyzer_uninstall() { - - // nothing to uninstall - // do not delete table - - return true; -} - - -/** - * Summary of PluginMailAnalyzer - */ -class PluginMailAnalyzer { - - /** - * Create default mailgate - * @param int $mailcollectors_id is the id of the mail collector in GLPI DB - * @return bool|MailCollector - */ - static function openMailgate($mailcollectors_id) : PluginMailanalyzerMailCollector { - - $mailgate = new PluginMailanalyzerMailCollector(); - $mailgate->getFromDB($mailcollectors_id); - $mailgate->uid = -1; - $mailgate->connect(); - - return $mailgate; - } - - - /** - * Summary of plugin_pre_item_add_mailanalyzer - * @param mixed $parm - * @return void - */ - public static function plugin_pre_item_add_mailanalyzer($parm) { - global $DB, $mailgate; - - $mailgateId = $parm->input['_mailgate'] ?? false; - if ($mailgateId) { - // this ticket have been created via email receiver. - // Analyzes emails to establish conversation - - // search for 'Thread-Index'? - $config = Config::getConfigurationValues('plugin:mailanalyzer'); - $use_threadindex = isset($config['use_threadindex']) && $config['use_threadindex']; - - if (isset($mailgate)) { - // mailgate has been open by web page call, then use it - $local_mailgate = $mailgate; - // if use of threadindex is true then must open a new mailgate - // to be able to get the threadindex of the email - if ($use_threadindex) { - $local_mailgate = PluginMailAnalyzer::openMailgate($mailgateId); - } - } else { - // mailgate is not open. Called by cron - // then locally create a mailgate - $local_mailgate = PluginMailAnalyzer::openMailgate($mailgateId); - if ($local_mailgate === false) { - // can't connect to the mail server, then cancel ticket creation - $parm->input = false;// []; // empty array... - return; - } - } - - if ($use_threadindex) { - $local_message = $local_mailgate->getMessage($parm->input['_uid']); - $threadindex = $local_mailgate->getThreadIndex($local_message); - if ($threadindex) { - // add threadindex to the '_head' of the input - $parm->input['_head']['threadindex'] = $threadindex; - } - } - - - // we must check if this email has not been received yet! - // test if 'message-id' is in the DB - $messageId = html_entity_decode($parm->input['_head']['message_id']); - $uid = $parm->input['_uid']; - $res = $DB->request( - 'glpi_plugin_mailanalyzer_message_id', - [ - 'AND' => - [ - 'tickets_id' => ['!=', 0], - 'message_id' => $messageId, - 'mailcollectors_id' => $mailgateId - ] - ] - ); - if ($row = $res->current()) { - // email already received - // must prevent ticket creation - $parm->input = false; //[ ]; - - // as Ticket creation is cancelled, then email is not deleted from mailbox - // then we need to set deletion flag to true to this email from mailbox folder - $local_mailgate->deleteMails($uid, MailCollector::REFUSED_FOLDER); // NOK Folder - - return; - } - - // search for 'Thread-Index' and 'References' - $messages_id = self::getMailReferences( - $parm->input['_head']['threadindex'] ?? '', - html_entity_decode($parm->input['_head']['references'] ?? '') - ); - - if (count($messages_id) > 0) { - $res = $DB->request( - 'glpi_plugin_mailanalyzer_message_id', - ['AND' => - [ - 'tickets_id' => ['!=',0], - 'message_id' => $messages_id, - 'mailcollectors_id' => $mailgateId - ], - 'ORDER' => 'tickets_id DESC' - ] - ); - if ($row = $res->current()) { - // TicketFollowup creation only if ticket status is not closed - $locTicket = new Ticket(); - $locTicket->getFromDB((integer)$row['tickets_id']); - if ($locTicket->fields['status'] != CommonITILObject::CLOSED) { - $ticketfollowup = new ITILFollowup(); - $input = $parm->input; - $input['items_id'] = $row['tickets_id']; - $input['users_id'] = $parm->input['_users_id_requester']; - $input['add_reopen'] = 1; - $input['itemtype'] = 'Ticket'; - - unset($input['urgency']); - unset($input['entities_id']); - unset($input['_ruleid']); - - $ticketfollowup->add($input); - - // add message id to DB in case of another email will use it - $DB->insert( - 'glpi_plugin_mailanalyzer_message_id', - [ - 'message_id' => $messageId, - 'tickets_id' => $input['items_id'], - 'mailcollectors_id' => $mailgateId - ] - ); - - // prevent Ticket creation. Unfortunately it will return an error to receiver when started manually from web page - $parm->input = false; // []; // empty array... - - // as Ticket creation is cancelled, then email is not deleted from mailbox - // then we need to set deletion flag to true to this email from mailbox folder - $local_mailgate->deleteMails($uid, MailCollector::ACCEPTED_FOLDER); // OK folder - - return; - - } else { - // ticket creation, but linked to the closed one... - $parm->input['_link'] = ['link' => '1', 'tickets_id_1' => '0', 'tickets_id_2' => $row['tickets_id']]; - } - } - } - - // can't find ref into DB, then this is a new ticket, in this case insert refs and message_id into DB - $messages_id[] = $messageId; - - // this is a new ticket - // then add references and message_id to DB - foreach ($messages_id as $ref) { - $res = $DB->request('glpi_plugin_mailanalyzer_message_id', ['message_id' => $ref, 'mailcollectors_id' => $mailgateId]); - if (count($res) <= 0) { - $DB->insert('glpi_plugin_mailanalyzer_message_id', ['message_id' => $ref, 'mailcollectors_id' => $mailgateId]); - } - } - } - } - - - /** - * Summary of plugin_item_add_mailanalyzer - * @param mixed $parm - */ - public static function plugin_item_add_mailanalyzer($parm) { - global $DB; - if (isset($parm->input['_mailgate'])) { - // this ticket have been created via email receiver. - // update the ticket ID for the message_id only for newly created tickets (tickets_id == 0) - - // Are 'Thread-Index' or 'Refrences' present? - $messages_id = self::getMailReferences( - $parm->input['_head']['threadindex'] ?? '', - html_entity_decode($parm->input['_head']['references'] ?? '') - ); - $messages_id[] = html_entity_decode($parm->input['_head']['message_id']); - - $DB->update( - 'glpi_plugin_mailanalyzer_message_id', - [ - 'tickets_id' => $parm->fields['id'] - ], - [ - 'WHERE' => - [ - 'AND' => - [ - 'tickets_id' => 0, - 'message_id' => $messages_id - ] - ] - ] - ); - } - } - - - /** - * Summary of getMailReferences - * @param string $threadindex - * @param string $references - * @return string[] - */ - private static function getMailReferences(string $threadindex, string $references) { - - $messages_id = []; // by default - - if (!empty($threadindex)) { - $messages_id[] = $threadindex; - } - - // search for 'References' - if (!empty($references)) { - // we may have a forwarded email that looks like reply-to - if (preg_match_all('/<.*?>/', $references, $matches)) { - $messages_id = array_merge($messages_id, $matches[0]); - } - } - - // clean $messages_id array - return array_filter($messages_id, function($val) {return $val != trim('', '< >');}); - } - - - /** - * Summary of plugin_item_purge_mailanalyzer - * @param mixed $item - */ - static function plugin_item_purge_mailanalyzer($item) { - global $DB; - // the ticket is purged, then we are going to purge the matching rows in glpi_plugin_mailanalyzer_message_id table - // DELETE FROM glpi_plugin - $DB->delete('glpi_plugin_mailanalyzer_message_id', ['tickets_id' => $item->getID()]); - } -} - +doQueryOrDie() with CREATE TABLE (only way to create + * a table that doesn't exist yet; Migration::addField() does + * ALTER TABLE ADD COLUMN which requires the table to exist). + * - Upgrade path → Migration::changeField() / addField() / addKey() for schema + * changes on an already existing table. + * + * All integer FK columns MUST be UNSIGNED to avoid the deprecation warning + * "Usage of signed integers in primary or foreign keys is discouraged". + */ +function plugin_mailanalyzer_install(): bool +{ + global $DB; + + $migration = new Migration(PLUGIN_MAILANALYZER_VERSION); + $table = 'glpi_plugin_mailanalyzer_message_id'; + + if (!$DB->tableExists($table)) { + // ── Fresh install ───────────────────────────────────────────────────── + // doQueryOrDie() is the correct method in GLPI 11 for DDL statements + // that cannot be expressed through the Migration column-by-column API + // (which only handles ALTER TABLE on existing tables). + $charset = \DBConnection::getDefaultCharset(); + $collation = \DBConnection::getDefaultCollation(); + + $DB->doQueryOrDie( + "CREATE TABLE `{$table}` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `message_id` VARCHAR(255) COLLATE {$collation} NOT NULL DEFAULT '0', + `tickets_id` INT UNSIGNED NOT NULL DEFAULT '0', + `mailcollectors_id` INT UNSIGNED NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `message_id` (`message_id`, `mailcollectors_id`), + KEY `tickets_id` (`tickets_id`) + ) ENGINE=InnoDB + DEFAULT CHARSET={$charset} + COLLATE={$collation};", + "Cannot create {$table}" + ); + } else { + // ── Upgrade path ────────────────────────────────────────────────────── + + // 1. Rename legacy mailgate_id → mailcollectors_id + if ($DB->fieldExists($table, 'mailgate_id') && !$DB->fieldExists($table, 'mailcollectors_id')) { + $migration->changeField($table, 'mailgate_id', 'mailcollectors_id', 'integer', [ + 'value' => 0, + 'unsigned' => true, + ]); + $migration->dropKey($table, 'message_id'); + $migration->addKey($table, ['message_id', 'mailcollectors_id'], 'message_id', 'UNIQUE'); + } + + // 2. Add mailcollectors_id if still completely absent + if (!$DB->fieldExists($table, 'mailcollectors_id')) { + $migration->addField($table, 'mailcollectors_id', 'integer', [ + 'value' => 0, + 'unsigned' => true, + 'after' => 'message_id', + ]); + $migration->dropKey($table, 'message_id'); + $migration->addKey($table, ['message_id', 'mailcollectors_id'], 'message_id', 'UNIQUE'); + } + + // 3. Rename ticket_id → tickets_id (very old schema) + if ($DB->fieldExists($table, 'ticket_id') && !$DB->fieldExists($table, 'tickets_id')) { + $migration->changeField($table, 'ticket_id', 'tickets_id', 'integer', [ + 'value' => 0, + 'unsigned' => true, + ]); + } + + // 4 & 5. Forzar UNSIGNED en tickets_id y mailcollectors_id. + // + // Migration::changeField() con tipo 'integer' genera INT(11) firmado + // ignorando la opción 'unsigned' => true. Para forzar UNSIGNED correctamente + // usamos ALTER TABLE directo con doQueryOrDie(), que es el mismo método + // que usa GLPI internamente en sus propias migraciones de unsigned_keys. + $alterCols = []; + if ($DB->fieldExists($table, 'tickets_id')) { + $alterCols[] = "MODIFY `tickets_id` INT UNSIGNED NOT NULL DEFAULT '0'"; + } + if ($DB->fieldExists($table, 'mailcollectors_id')) { + $alterCols[] = "MODIFY `mailcollectors_id` INT UNSIGNED NOT NULL DEFAULT '0'"; + } + if (!empty($alterCols)) { + $DB->doQueryOrDie( + "ALTER TABLE `{$table}` " . implode(', ', $alterCols), + "Cannot fix unsigned columns in {$table}" + ); + } + + $migration->executeMigration(); + } + + // Inicializar configuración por defecto si no existe aún + // Config::setConfigurationValues hace INSERT si no existe, UPDATE si ya existe + \Config::setConfigurationValues('plugin:mailanalyzer', [ + 'use_threadindex' => 0, + ]); + + return true; +} + + +/** + * Uninstall hook. + * + * Per the original plugin design: the tracking table is intentionally + * NOT dropped on uninstall so that historical threading data is preserved + * if the plugin is re-installed later. + */ +function plugin_mailanalyzer_uninstall(): bool +{ + // Intentionally left empty — table preserved on uninstall. + return true; +} diff --git a/mailanalyzer.xml b/mailanalyzer.xml index 60473a7..62e6bc6 100644 --- a/mailanalyzer.xml +++ b/mailanalyzer.xml @@ -1,92 +1,59 @@ - - - Mail Analyzer - mailanalyzer - stable - https://raw.githubusercontent.com/tomolimo/mailanalyzer/master/plugin.png - - - - - - - - - - - https://github.com/tomolimo/mailanalyzer - https://github.com/tomolimo/mailanalyzer/releases - https://github.com/tomolimo/mailanalyzer/issues - https://github.com/tomolimo/mailanalyzer/wiki - - Olivier Moron - - - - 3.2.2 - ~10.0.18 - https://github.com/tomolimo/mailanalyzer/releases/download/3.2.2/mailanalyzer-3.2.2.zip - - - 3.2.0 - ~10.0.0 - https://github.com/tomolimo/mailanalyzer/releases/download/3.2.0/mailanalyzer-3.2.0.zip - - - 2.1.0 - ~9.5 - https://github.com/tomolimo/mailanalyzer/releases/download/2.1.0/mailanalyzer-2.1.0.zip - - - 1.6.5 - 9.4 - https://github.com/tomolimo/mailanalyzer/releases/download/1.6.5/mailanalyzer-1.6.5.zip - - - 1.5.2 - 9.3 - - - 1.4.3 - 9.2 - - - 1.3.8 - 9.1 - - - 1.2.5 - 0.90 - - - 1.2.5 - 0.85 - - - 1.2.5 - 0.84 - - - 1.2.5 - 0.83 - - - - Any - - - - - E-mail - E-mailový sběrač - E-mailová brána - - - Mail - Mail collector - Mailgate - - - + + + Mail Analyzer + mailanalyzer + stable + https://raw.githubusercontent.com/tomolimo/mailanalyzer/master/plugin.png + + + + + + + + + https://github.com/tomolimo/mailanalyzer + https://github.com/tomolimo/mailanalyzer/releases + https://github.com/tomolimo/mailanalyzer/issues + https://github.com/tomolimo/mailanalyzer/wiki + + Olivier Moron + + + + 4.0.0 + ~11.0 + https://github.com/tomolimo/mailanalyzer/releases/download/4.0.0/mailanalyzer-4.0.0.zip + + + 3.2.2 + ~10.0.18 + https://github.com/tomolimo/mailanalyzer/releases/download/3.2.2/mailanalyzer-3.2.2.zip + + + 3.2.0 + ~10.0.0 + https://github.com/tomolimo/mailanalyzer/releases/download/3.2.0/mailanalyzer-3.2.0.zip + + + 2.1.0 + ~9.5 + https://github.com/tomolimo/mailanalyzer/releases/download/2.1.0/mailanalyzer-2.1.0.zip + + + + Any + + + + + Mail + Mail collector + Mailgate + Threading + Ticket deduplication + + + diff --git a/setup.php b/setup.php index c7e8d3d..27988ca 100644 --- a/setup.php +++ b/setup.php @@ -1,123 +1,113 @@ -. --------------------------------------------------------------------------- - */ - -define ("PLUGIN_MAILANALYZER_VERSION", "4.0.0"); -// Minimal GLPI version, inclusive -define('PLUGIN_MAILANALYZER_MIN_GLPI', '11.0'); -// Maximum GLPI version, exclusive -define('PLUGIN_MAILANALYZER_MAX_GLPI', '11.1'); - -/** - * Summary of plugin_init_mailanalyzer - * Init the hooks of the plugins - */ -function plugin_init_mailanalyzer() { - - global $PLUGIN_HOOKS; - - Plugin::registerClass('PluginMailAnalyzer'); - - $PLUGIN_HOOKS['csrf_compliant']['mailanalyzer'] = true; - - $PLUGIN_HOOKS['pre_item_add']['mailanalyzer'] = [ - 'Ticket' => ['PluginMailAnalyzer', 'plugin_pre_item_add_mailanalyzer'], - ]; - - $PLUGIN_HOOKS['item_add']['mailanalyzer'] = [ - 'Ticket' => ['PluginMailAnalyzer', 'plugin_item_add_mailanalyzer'] - ]; - - $PLUGIN_HOOKS['item_purge']['mailanalyzer'] = [ - 'Ticket' => ['PluginMailAnalyzer', 'plugin_item_purge_mailanalyzer'] - ]; - - if (Session::haveRightsOr("config", [READ, UPDATE])) { - Plugin::registerClass('PluginMailanalyzerConfig', ['addtabon' => 'Config']); - $PLUGIN_HOOKS['config_page']['mailanalyzer'] = 'front/config.form.php'; - } - -} - - -/** - * Summary of plugin_version_mailanalyzer - * Get the name and the version of the plugin - * @return array - */ -function plugin_version_mailanalyzer() { - return [ - 'name' => __('Mail Analyzer'), - 'version' => PLUGIN_MAILANALYZER_VERSION, - 'author' => 'Olivier Moron', - 'license' => 'GPLv2+', - 'homepage' => 'https://github.com/tomolimo/mailanalyzer', - 'requirements' => [ - 'glpi' => [ - 'min' => PLUGIN_MAILANALYZER_MIN_GLPI, - 'max' => PLUGIN_MAILANALYZER_MAX_GLPI - ] - ] - ]; -} - - -/** - * Summary of plugin_mailanalyzer_check_prerequisites - * check prerequisites before install : may print errors or add to message after redirect - * @return bool - */ -function plugin_mailanalyzer_check_prerequisites() { - if (version_compare(GLPI_VERSION, PLUGIN_MAILANALYZER_MIN_GLPI, 'lt') - && version_compare(GLPI_VERSION, PLUGIN_MAILANALYZER_MAX_GLPI, 'ge')) { - echo "This plugin requires GLPI >= " . PLUGIN_MAILANALYZER_MIN_GLPI ." and < " . PLUGIN_MAILANALYZER_MAX_GLPI; - return false; - } else { - if (!class_exists('mailanalyzer_check_prerequisites')) { - class mailanalyzer_check_prerequisites { public $attr = 'value'; function __toString() { - return 'empty';}}; - } - $loc = new mailanalyzer_check_prerequisites; - $loc2 = Toolbox::addslashes_deep($loc); - if (is_object($loc2) && $loc->attr === $loc2->attr) { - return true; - } else { - echo "This plugin requires upgraded versions of mailcollector.class.php and toolbox.class.php"; - return false; - } - } -} - - -/** - * Summary of plugin_mailanalyzer_check_config - * @return bool - */ -function plugin_mailanalyzer_check_config() { - return true; -} - +. +-------------------------------------------------------------------------- + */ + +use Glpi\Plugin\Hooks; +use GlpiPlugin\Mailanalyzer\Config; +use GlpiPlugin\Mailanalyzer\MailAnalyzer; + +define('PLUGIN_MAILANALYZER_VERSION', '4.0.0'); + +// Minimal GLPI version, inclusive +define('PLUGIN_MAILANALYZER_MIN_GLPI', '11.0.0'); +// Maximum GLPI version, exclusive +define('PLUGIN_MAILANALYZER_MAX_GLPI', '12.0.0'); + +/** + * Init the hooks of the plugin. + */ +function plugin_init_mailanalyzer(): void +{ + global $PLUGIN_HOOKS; + + $PLUGIN_HOOKS[Hooks::CSRF_COMPLIANT]['mailanalyzer'] = true; + + if (!Plugin::isPluginActive('mailanalyzer')) { + return; + } + + // Register hooks for Ticket lifecycle + $PLUGIN_HOOKS[Hooks::PRE_ITEM_ADD]['mailanalyzer'] = [ + 'Ticket' => [MailAnalyzer::class, 'preItemAdd'], + ]; + + $PLUGIN_HOOKS[Hooks::ITEM_ADD]['mailanalyzer'] = [ + 'Ticket' => [MailAnalyzer::class, 'itemAdd'], + ]; + + $PLUGIN_HOOKS[Hooks::ITEM_PURGE]['mailanalyzer'] = [ + 'Ticket' => [MailAnalyzer::class, 'itemPurge'], + ]; + + // Config tab on Setup > General + if (Session::haveRightsOr('config', [READ, UPDATE])) { + Plugin::registerClass(Config::class, ['addtabon' => \Config::class]); + $PLUGIN_HOOKS[Hooks::CONFIG_PAGE]['mailanalyzer'] = 'front/config.form.php'; + } +} + + +/** + * Get the name and the version of the plugin. + */ +function plugin_version_mailanalyzer(): array +{ + return [ + 'name' => __('Mail Analyzer', 'mailanalyzer'), + 'version' => PLUGIN_MAILANALYZER_VERSION, + 'author' => 'Olivier Moron', + 'license' => 'GPLv2+', + 'homepage' => 'https://github.com/tomolimo/mailanalyzer', + 'requirements' => [ + 'glpi' => [ + 'min' => PLUGIN_MAILANALYZER_MIN_GLPI, + 'max' => PLUGIN_MAILANALYZER_MAX_GLPI, + ], + 'php' => [ + 'min' => '8.1', + ], + ], + ]; +} + + +/** + * Check prerequisites before install. + */ +function plugin_mailanalyzer_check_prerequisites(): bool +{ + return true; +} + + +/** + * Check config. + */ +function plugin_mailanalyzer_check_config(): bool +{ + return true; +} diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..5529774 --- /dev/null +++ b/src/Config.php @@ -0,0 +1,136 @@ + General. + * + * Estrategia final GLPI 11: + * - El form hace POST al MISMO endpoint que lo renderiza: ajax/common.tabs.php + * no — eso tampoco funciona. + * - Solución: form con action al front del plugin + AJAX submit via JS para + * evitar el reload de página, usando el token CSRF generado por Html::closeForm(). + * + * Estrategia real final: + * - Usar el hook nativo de GLPI correctamente: el form apunta al core + * front/config.form.php, Html::closeForm() agrega el CSRF token, + * y config_class llama configUpdate() en el plugin. + * - El problema anterior NO era el mecanismo — era que configUpdate() no + * estaba siendo llamado porque en GLPI 11 src/Config.php cambió el flujo. + * - Verificar el flujo real leyendo src/Config.php de GLPI 11. + */ +class Config extends GlpiConfig +{ + public static function getTypeName($nb = 0): string + { + return __('Mail Analyzer', 'mailanalyzer'); + } + + public function getTabNameForItem(CommonGLPI $item, $withtemplate = 0): string + { + if ($item instanceof GlpiConfig) { + return self::createTabEntry(__('Mail Analyzer', 'mailanalyzer')); + } + return ''; + } + + public static function displayTabContentForItem( + CommonGLPI $item, + $tabnum = 1, + $withtemplate = 0 + ): bool { + if ($item instanceof GlpiConfig) { + self::showConfigForm($item); + } + return true; + } + + /** + * Llamado por Config::prepareInputForUpdate() cuando config_class está en el POST. + * Normaliza los valores antes de que sean guardados. + */ + public static function configUpdate(array $input): array + { + $input['use_threadindex'] = isset($input['use_threadindex']) ? (int)$input['use_threadindex'] : 0; + return $input; + } + + public static function showConfigForm(GlpiConfig $item): void + { + $config = GlpiConfig::getConfigurationValues('plugin:mailanalyzer'); + + if (!isset($config['use_threadindex'])) { + GlpiConfig::setConfigurationValues('plugin:mailanalyzer', ['use_threadindex' => 0]); + $config['use_threadindex'] = 0; + } + + $use_threadindex = (int) $config['use_threadindex']; + $canedit = Session::haveRight('config', UPDATE); + + // El form apunta al ajax handler del plugin que guarda directamente + // usando el mismo contexto de sesión que el tab (mismo dominio, mismo request) + $form_action = \Plugin::getWebDir('mailanalyzer') . '/front/save_config.php'; + + echo '
'; + + echo '
'; + echo '
'; + echo '

'; + echo ''; + echo htmlspecialchars(__('Mail Analyzer setup', 'mailanalyzer')); + echo '

'; + echo '
'; + + echo '
'; + echo '
'; + + echo ''; + + echo '
'; + Dropdown::showYesNo('use_threadindex', $use_threadindex, -1, [ + 'disabled' => !$canedit, + ]); + echo '
'; + echo htmlspecialchars( + __('When enabled, the Microsoft Thread-Index header is used in addition to References to link emails to existing tickets.', 'mailanalyzer') + ); + echo '
'; + echo '
'; + + echo '
'; + echo '
'; + + if ($canedit) { + echo ''; + } + + echo '
'; + + // Html::closeForm() cierra y agrega _glpi_csrf_token automáticamente + Html::closeForm(); + } +} diff --git a/src/MailAnalyzer.php b/src/MailAnalyzer.php new file mode 100644 index 0000000..362fb6f --- /dev/null +++ b/src/MailAnalyzer.php @@ -0,0 +1,278 @@ +query(). All DB access uses $DB->request() / insert() / update() / delete(). + * - No Toolbox::addslashes_deep(): GLPI 11 removed auto-sanitization; data is raw. + * - Hook callbacks are now public static methods referenced via ::class constant in setup.php. + * - openMailgate() returns PluginMailanalyzerMailCollector (our PSR-4 class). + */ +class MailAnalyzer +{ + /** + * Open a connection to a mail collector. + * + * @param int $mailcollectors_id + * @return MailCollectorWrapper + */ + private static function openMailgate(int $mailcollectors_id): MailCollectorWrapper + { + $mailgate = new MailCollectorWrapper(); + $mailgate->getFromDB($mailcollectors_id); + $mailgate->uid = -1; + $mailgate->connect(); + + return $mailgate; + } + + + /** + * Hook: pre_item_add on Ticket. + * + * Intercepts tickets created via the mail collector and either: + * - Blocks creation (duplicate message-id already in DB), or + * - Converts it into a followup on an existing ticket (email threading), or + * - Allows creation and pre-registers references. + * + * @param Ticket $item + */ + public static function preItemAdd(Ticket $item): void + { + global $DB, $mailgate; + + $mailgateId = $item->input['_mailgate'] ?? false; + if (!$mailgateId) { + return; + } + + // Config: should we use Thread-Index header? + $config = Config::getConfigurationValues('plugin:mailanalyzer'); + $use_threadindex = isset($config['use_threadindex']) && $config['use_threadindex']; + + // Resolve which mail connection to use + if (isset($mailgate)) { + $local_mailgate = $mailgate; + if ($use_threadindex) { + // Need a fresh connection to fetch Thread-Index + $local_mailgate = self::openMailgate($mailgateId); + } + } else { + // Called from cron — open our own connection + $local_mailgate = self::openMailgate($mailgateId); + if ($local_mailgate === false) { + $item->input = false; + return; + } + } + + // Optionally extract Thread-Index header + if ($use_threadindex) { + $local_message = $local_mailgate->getMessage($item->input['_uid']); + $threadindex = $local_mailgate->getThreadIndex($local_message); + if ($threadindex) { + $item->input['_head']['threadindex'] = $threadindex; + } + } + + // GLPI 11: html_entity_decode() is still fine here; data is raw but may come + // from the mail headers which can be HTML-encoded. + $messageId = html_entity_decode($item->input['_head']['message_id']); + $uid = $item->input['_uid']; + + // --- 1. Check for duplicate message-id --- + $res = $DB->request([ + 'FROM' => 'glpi_plugin_mailanalyzer_message_id', + 'WHERE' => [ + 'AND' => [ + ['tickets_id' => ['!=', 0]], + ['message_id' => $messageId], + ['mailcollectors_id' => $mailgateId], + ], + ], + ]); + + if ($res->current()) { + // Already imported — refuse and move to NOK folder + $item->input = false; + $local_mailgate->deleteMails($uid, MailCollector::REFUSED_FOLDER); + return; + } + + // --- 2. Check references / Thread-Index for threading --- + $messages_id = self::getMailReferences( + $item->input['_head']['threadindex'] ?? '', + html_entity_decode($item->input['_head']['references'] ?? '') + ); + + if (count($messages_id) > 0) { + $res = $DB->request([ + 'FROM' => 'glpi_plugin_mailanalyzer_message_id', + 'WHERE' => [ + 'AND' => [ + ['tickets_id' => ['!=', 0]], + ['message_id' => $messages_id], + ['mailcollectors_id' => $mailgateId], + ], + ], + 'ORDER' => ['tickets_id DESC'], + ]); + + if ($row = $res->current()) { + $locTicket = new Ticket(); + $locTicket->getFromDB((int) $row['tickets_id']); + + if ($locTicket->fields['status'] !== CommonITILObject::CLOSED) { + // Convert to a followup on the existing ticket + $ticketfollowup = new ITILFollowup(); + $input = $item->input; + $input['items_id'] = $row['tickets_id']; + $input['users_id'] = $item->input['_users_id_requester']; + $input['add_reopen'] = 1; + $input['itemtype'] = 'Ticket'; + + unset($input['urgency'], $input['entities_id'], $input['_ruleid']); + + $ticketfollowup->add($input); + + // Track this message-id too + $DB->insert('glpi_plugin_mailanalyzer_message_id', [ + 'message_id' => $messageId, + 'tickets_id' => $input['items_id'], + 'mailcollectors_id' => $mailgateId, + ]); + + // Cancel ticket creation and move mail to OK folder + $item->input = false; + $local_mailgate->deleteMails($uid, MailCollector::ACCEPTED_FOLDER); + return; + } + + // Ticket is closed → allow new ticket but link it + $item->input['_link'] = [ + 'link' => '1', + 'tickets_id_1' => '0', + 'tickets_id_2' => $row['tickets_id'], + ]; + } + } + + // --- 3. New ticket — pre-register all references and message_id --- + $messages_id[] = $messageId; + + foreach ($messages_id as $ref) { + $existing = $DB->request([ + 'FROM' => 'glpi_plugin_mailanalyzer_message_id', + 'WHERE' => [ + 'message_id' => $ref, + 'mailcollectors_id' => $mailgateId, + ], + ]); + + if (count($existing) === 0) { + $DB->insert('glpi_plugin_mailanalyzer_message_id', [ + 'message_id' => $ref, + 'mailcollectors_id' => $mailgateId, + ]); + } + } + } + + + /** + * Hook: item_add on Ticket. + * + * After the ticket is created, update the tickets_id for all pre-registered + * references (tickets_id == 0). + * + * @param Ticket $item + */ + public static function itemAdd(Ticket $item): void + { + global $DB; + + if (!isset($item->input['_mailgate'])) { + return; + } + + $messages_id = self::getMailReferences( + $item->input['_head']['threadindex'] ?? '', + html_entity_decode($item->input['_head']['references'] ?? '') + ); + $messages_id[] = html_entity_decode($item->input['_head']['message_id']); + + $DB->update( + 'glpi_plugin_mailanalyzer_message_id', + ['tickets_id' => $item->fields['id']], + [ + 'WHERE' => [ + 'AND' => [ + 'tickets_id' => 0, + 'message_id' => $messages_id, + ], + ], + ] + ); + } + + + /** + * Hook: item_purge on Ticket. + * + * Remove all message-id records linked to the purged ticket. + * + * @param Ticket $item + */ + public static function itemPurge(Ticket $item): void + { + global $DB; + + $DB->delete('glpi_plugin_mailanalyzer_message_id', [ + 'tickets_id' => $item->getID(), + ]); + } + + + /** + * Build the list of known message references from Thread-Index and References headers. + * + * @param string $threadindex Base64-encoded Thread-Index header value (may be empty) + * @param string $references RFC 2822 References header value (may be empty) + * @return string[] + */ + private static function getMailReferences(string $threadindex, string $references): array + { + $messages_id = []; + + if (!empty($threadindex)) { + $messages_id[] = $threadindex; + } + + if (!empty($references)) { + if (preg_match_all('/<.*?>/', $references, $matches)) { + $messages_id = array_merge($messages_id, $matches[0]); + } + } + + // Filter out empty angle-bracket tokens + return array_filter($messages_id, static fn($val) => $val !== trim('', '< >')); + } +} diff --git a/src/MailCollectorWrapper.php b/src/MailCollectorWrapper.php new file mode 100644 index 0000000..e579878 --- /dev/null +++ b/src/MailCollectorWrapper.php @@ -0,0 +1,174 @@ +fields['host']. + * + * @throws \Throwable on connection failure + */ + public function connect(): void + { + $config = Toolbox::parseMailServerConnectString($this->fields['host']); + + $params = [ + 'host' => $config['address'], + 'user' => $this->fields['login'], + 'password' => (new GLPIKey())->decrypt($this->fields['passwd']), + 'port' => $config['port'], + ]; + + if ($config['ssl']) { + $params['ssl'] = 'SSL'; + } + + if ($config['tls']) { + $params['ssl'] = 'TLS'; + } + + if (!empty($config['mailbox'])) { + $params['folder'] = mb_convert_encoding($config['mailbox'], 'UTF7-IMAP', 'UTF-8'); + } + + if ($config['validate-cert'] === false) { + $params['novalidatecert'] = true; + } + + try { + $storage = Toolbox::getMailServerStorageInstance($config['type'], $params); + if ($storage === null) { + throw new \RuntimeException( + sprintf(__('Unsupported mail server type: %s.'), $config['type']) + ); + } + $this->storage = $storage; + + // Reset error counter on successful connection + if ($this->fields['errors'] > 0) { + $this->update(['id' => $this->getID(), 'errors' => 0]); + } + } catch (\Throwable $e) { + $this->update(['id' => $this->getID(), 'errors' => ($this->fields['errors'] + 1)]); + throw $e; + } + } + + /** + * Retrieve the Thread-Index header value from a Laminas message. + * + * The Thread-Index header is a Microsoft-specific base64-encoded blob. + * We extract bytes 6–21 (16 bytes) and hex-encode them to get a stable + * string key for threading. + * + * @param Message $message + * @return string|null Hex string, or null if header is absent + */ + public function getThreadIndex(Message $message): ?string + { + if (!isset($message->threadindex)) { + return null; + } + + $header = $message->getHeader('threadindex'); + if ($header === null) { + return null; + } + + return bin2hex(substr(base64_decode($header->getFieldValue()), 6, 16)); + } + + /** + * Retrieve a Laminas Message object by UID. + * + * @param mixed $uid Mail UID as stored in GLPI's ticket input + * @return Message + */ + public function getMessage($uid): Message + { + return $this->storage->getMessage( + $this->storage->getNumberByUniqueId($uid) + ); + } + + /** + * Move or delete a mail from the mailbox. + * + * Mirrors MailCollector::deleteMails() but operates on our storage handle. + * + * @param mixed $uid Mail UID + * @param string $folder Folder constant from MailCollector (ACCEPTED_FOLDER / REFUSED_FOLDER) + * Pass '' to delete without moving. + */ + public function deleteMails($uid, string $folder = ''): bool + { + // POP3 has no folders — always delete + if (strstr($this->fields['host'], '/pop')) { + $folder = ''; + } + + if (!empty($folder) && isset($this->fields[$folder]) && !empty($this->fields[$folder])) { + $name = mb_convert_encoding($this->fields[$folder], 'UTF7-IMAP', 'UTF-8'); + try { + $this->storage->moveMessage( + $this->storage->getNumberByUniqueId($uid), + $name + ); + return true; + } catch (\Throwable $e) { + trigger_error( + sprintf( + __('Invalid configuration for %1$s folder in receiver %2$s'), + $folder, + $this->getName() + ) + ); + // Fall through to delete + } + } + + $this->storage->removeMessage($this->storage->getNumberByUniqueId($uid)); + return true; + } +} diff --git a/templates/pages/config.html.twig b/templates/pages/config.html.twig new file mode 100644 index 0000000..984987c --- /dev/null +++ b/templates/pages/config.html.twig @@ -0,0 +1,77 @@ +{# + ------------------------------------------------------------------------- + MailAnalyzer plugin for GLPI — config.html.twig + Copyright (C) 2011-2025 by Raynet SAS a company of A.Raymond Network. + ------------------------------------------------------------------------- + GLPI 11: self-contained template, rendered via TemplateRenderer. + Uses Tabler/Bootstrap 5 utility classes — no custom CSS required. + #} + +
+ +
+
+

+ + {{ __('Mail Analyzer setup', 'mailanalyzer') }} +

+
+ +
+
+ +
+
+ {# + El campo hidden con value="0" garantiza que el POST siempre + envía use_threadindex, incluso cuando el checkbox no está + marcado. El checkbox (value="1") lo sobreescribe si está activo. + Sin esto, GLPI Config::setConfigurationValues() no recibe + el campo y mantiene el valor anterior → el toggle nunca se apaga. + #} + + + +
+ + {{ __('When enabled, the Microsoft Thread-Index header is used in addition to References to link emails to existing tickets.', 'mailanalyzer') }} + +
+
+
+ + {% if can_update %} + + {% endif %} +
+ + {# Campos requeridos por el handler Config::update de GLPI #} + + + + + From 8d2410e96b83623b6bacb138e64f77c310f2a6a5 Mon Sep 17 00:00:00 2001 From: Giovanny Rodriguez Date: Tue, 30 Jun 2026 14:32:27 -0500 Subject: [PATCH 2/2] Address review feedback on PR #100 - Remove legacy inc/config.class.php and inc/mailcollector.class.php, fully superseded by src/Config.php and src/MailCollectorWrapper.php - Wire up templates/pages/config.html.twig via TemplateRenderer in src/Config.php instead of duplicating the form as inline HTML; drop dead Config::configUpdate() (never called) - Bump version to 4.1.0 (4.0.0 already released) - Add locales/mailanalyzer.pot translation template - Translate remaining Spanish comments to English --- CHANGELOG.md | 8 ++ README.md | 2 +- front/config.form.php | 5 +- front/save_config.php | 20 ++--- hook.php | 14 +-- inc/config.class.php | 105 ---------------------- inc/mailcollector.class.php | 150 ------------------------------- locales/mailanalyzer.pot | 49 ++++++++++ mailanalyzer.xml | 5 ++ setup.php | 2 +- src/Config.php | 87 ++---------------- src/MailAnalyzer.php | 2 +- src/MailCollectorWrapper.php | 4 +- templates/pages/config.html.twig | 15 ++-- 14 files changed, 101 insertions(+), 367 deletions(-) delete mode 100644 inc/config.class.php delete mode 100644 inc/mailcollector.class.php create mode 100644 locales/mailanalyzer.pot diff --git a/CHANGELOG.md b/CHANGELOG.md index b2bf275..7f42692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [4.1.0] — 2026-06-30 + +- Removed legacy `inc/config.class.php` and `inc/mailcollector.class.php`, fully superseded by `src/Config.php` and `src/MailCollectorWrapper.php` +- `src/Config.php` now actually renders `templates/pages/config.html.twig` via `TemplateRenderer` instead of duplicating the form as inline HTML +- Removed dead `Config::configUpdate()` (never called: the form posts to `front/save_config.php`, not through core's `Config::update()`/`config_class` flow) +- Added `locales/mailanalyzer.pot` translation template +- Translated remaining non-English code comments + ## [4.0.0] — 2026-03-16 ### GLPI 11 compatibility diff --git a/README.md b/README.md index 70638d8..094e53d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ When an email is sent to GLPI **and** CC'd to other recipients, and those CC rec | Plugin version | GLPI version | |---|---| -| **4.0.x** | **11.0.x** | +| **4.1.x** | **11.0.x** | | 3.2.x | 10.0.18+ | | 2.1.x | 9.5.x | diff --git a/front/config.form.php b/front/config.form.php index cb9f6f3..4bc59c9 100644 --- a/front/config.form.php +++ b/front/config.form.php @@ -9,9 +9,8 @@ */ /** - * GLPI 11: redirigir al tab Mail Analyzer en Setup > General. - * El POST lo maneja el core (front/config.form.php del core). - * Este archivo solo existe para el hook config_page. + * GLPI 11: redirect to the Mail Analyzer tab on Setup > General. + * This file only exists to satisfy the Hooks::CONFIG_PAGE hook target. */ Session::setActiveTab('Config', 'GlpiPlugin\Mailanalyzer\Config$1'); Html::redirect(Toolbox::getItemTypeFormURL('Config')); diff --git a/front/save_config.php b/front/save_config.php index 7a83c3b..ab0830e 100644 --- a/front/save_config.php +++ b/front/save_config.php @@ -9,17 +9,17 @@ */ /** - * Endpoint en front/ para guardar la configuración del plugin. + * Endpoint under front/ that saves the plugin configuration. * * GLPI 11 CSRF rules: - * - front/ y report/ → GLPI verifica $_POST['_glpi_csrf_token'] automáticamente - * ANTES de que este archivo se ejecute (en inc/includes.php / Firewall). - * Html::closeForm() inyecta ese token en el form → compatible. + * - front/ and report/ → GLPI automatically verifies $_POST['_glpi_csrf_token'] + * BEFORE this file is executed (in the GLPI Firewall). The Twig template + * renders that token via csrf_token(), so the form is compatible. * - * - ajax/ → GLPI busca el header HTTP 'X-Glpi-Csrf-Token' (no $_POST) - * → INCOMPATIBLE con form POST normal. + * - ajax/ → GLPI looks for the 'X-Glpi-Csrf-Token' HTTP header instead + * (not $_POST) → incompatible with a regular form POST. * - * Por eso el endpoint está aquí en front/ y no en ajax/. + * That's why this endpoint lives in front/ and not in ajax/. */ if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isset($_POST['update'])) { @@ -27,14 +27,14 @@ return; } -// El CSRF ya fue validado por el Firewall de GLPI antes de llegar aquí. -// Solo verificar permisos. +// CSRF was already validated by the GLPI Firewall before reaching here. +// Only the user's rights need to be checked. if (!Session::haveRight('config', UPDATE)) { Html::displayRightError(); return; } -// Guardar configuración +// Save configuration $use_threadindex = isset($_POST['use_threadindex']) ? (int) $_POST['use_threadindex'] : 0; Config::setConfigurationValues('plugin:mailanalyzer', [ diff --git a/hook.php b/hook.php index fe88078..1f33587 100644 --- a/hook.php +++ b/hook.php @@ -82,12 +82,12 @@ function plugin_mailanalyzer_install(): bool ]); } - // 4 & 5. Forzar UNSIGNED en tickets_id y mailcollectors_id. + // 4 & 5. Force UNSIGNED on tickets_id and mailcollectors_id. // - // Migration::changeField() con tipo 'integer' genera INT(11) firmado - // ignorando la opción 'unsigned' => true. Para forzar UNSIGNED correctamente - // usamos ALTER TABLE directo con doQueryOrDie(), que es el mismo método - // que usa GLPI internamente en sus propias migraciones de unsigned_keys. + // Migration::changeField() with type 'integer' generates a signed INT(11), + // ignoring the 'unsigned' => true option. To force UNSIGNED correctly we + // run a direct ALTER TABLE via doQueryOrDie(), the same method GLPI uses + // internally for its own unsigned_keys migrations. $alterCols = []; if ($DB->fieldExists($table, 'tickets_id')) { $alterCols[] = "MODIFY `tickets_id` INT UNSIGNED NOT NULL DEFAULT '0'"; @@ -105,8 +105,8 @@ function plugin_mailanalyzer_install(): bool $migration->executeMigration(); } - // Inicializar configuración por defecto si no existe aún - // Config::setConfigurationValues hace INSERT si no existe, UPDATE si ya existe + // Initialize default configuration if it doesn't already exist. + // Config::setConfigurationValues does an INSERT if missing, UPDATE otherwise. \Config::setConfigurationValues('plugin:mailanalyzer', [ 'use_threadindex' => 0, ]); diff --git a/inc/config.class.php b/inc/config.class.php deleted file mode 100644 index 946be28..0000000 --- a/inc/config.class.php +++ /dev/null @@ -1,105 +0,0 @@ -. --------------------------------------------------------------------------- - */ - -class PluginMailanalyzerConfig extends CommonDBTM { - - /** - * Summary of getTypeName - * @param mixed $nb plural - * @return mixed - */ - static function getTypeName($nb = 0) { - return __('Mail Analyzer setup', 'mailanalyzer'); - } - - /** - * Summary of getName - * @param mixed $with_comment with comment - * @return mixed - */ - function getName($with_comment = 0) { - return __('MailAnalyzer', 'mailanalyzer'); - } - - - /** - * Summary of showConfigForm - * @param mixed $item is the config - * @return boolean - */ - static function showConfigForm($item) { - $config = Config::getConfigurationValues('plugin:mailanalyzer'); - - echo "
"; - echo "
"; - echo "
"; - - echo ""; - - echo ""; - echo ""; - - echo ""; - - echo ""; - echo ""; - echo "
" . __('Mail Analyzer setup', 'mailanalyzer') . "
".__('Use of Thread index', 'mailanalyzer') .""; - if (!isset($config['use_threadindex'])) { - $config['use_threadindex'] = 0; - } - Dropdown::showYesNo("use_threadindex", $config['use_threadindex']); - echo "
"; - echo ""; - echo "
"; - - echo ""; - echo ""; - - Html::closeForm(); - - return false; - } - - - function getTabNameForItem(CommonGLPI $item, $withtemplate = 0) { - if ($item->getType()=='Config') { - return __('Mail Analyzer', 'mailanalyzer'); - } - return ''; - } - - - static function displayTabContentForItem(CommonGLPI $item, $tabnum = 1, $withtemplate = 0) { - - if ($item->getType()=='Config') { - self::showConfigForm($item); - } - return true; - } - -} diff --git a/inc/mailcollector.class.php b/inc/mailcollector.class.php deleted file mode 100644 index e515b45..0000000 --- a/inc/mailcollector.class.php +++ /dev/null @@ -1,150 +0,0 @@ -. --------------------------------------------------------------------------- --*/ - -use Laminas\Mail\Storage\Message; - -class PluginMailanalyzerMailCollector extends CommonDBTM -{ - private $storage; - public $uid = -1; - - public static function getTable($classname = null) { - return MailCollector::getTable(); - } - - public function connect() - { - $config = Toolbox::parseMailServerConnectString($this->fields['host']); - - $params = [ - 'host' => $config['address'], - 'user' => $this->fields['login'], - 'password' => (new GLPIKey())->decrypt($this->fields['passwd']), - 'port' => $config['port'] - ]; - - if ($config['ssl']) { - $params['ssl'] = 'SSL'; - } - - if ($config['tls']) { - $params['ssl'] = 'TLS'; - } - - if (!empty($config['mailbox'])) { - $params['folder'] = mb_convert_encoding($config['mailbox'], 'UTF7-IMAP', 'UTF-8'); - } - - if ($config['validate-cert'] === false) { - $params['novalidatecert'] = true; - } - - try { - $storage = Toolbox::getMailServerStorageInstance($config['type'], $params); - if ($storage === null) { - throw new \Exception(sprintf(__('Unsupported mail server type:%s.'), $config['type'])); - } - $this->storage = $storage; - if ($this->fields['errors'] > 0) { - $this->update([ - 'id' => $this->getID(), - 'errors' => 0 - ]); - } - } catch (\Throwable $e) { - $this->update([ - 'id' => $this->getID(), - 'errors' => ($this->fields['errors'] + 1) - ]); - // Any errors will cause an Exception. - throw $e; - } - } - - /** - * Summary of getThreadIndex - * @param Message $message - * @return string|null - */ - public function getThreadIndex(Message $message) { - if (isset($message->threadindex)) { - if ($val = $message->getHeader('threadindex')) { - return bin2hex(substr(base64_decode($val->getFieldValue()), 6, 16 )); - } - } - return null; - } - - /** - * Summary of getMessage - * @param mixed $uid - * @return Message - */ - public function getMessage($uid) : Message { - return $this->storage->getMessage($this->storage->getNumberByUniqueId($uid)); - } - - /** - * Delete mail from that mail box - * - * @param string $uid mail UID - * @param string $folder Folder to move (delete if empty) (default '') - * - * @return boolean - **/ - public function deleteMails($uid, $folder = '') - { - - // Disable move support, POP protocol only has the INBOX folder - if (strstr($this->fields['host'], "/pop")) { - $folder = ''; - } - - if (!empty($folder) && isset($this->fields[$folder]) && !empty($this->fields[$folder])) { - $name = mb_convert_encoding($this->fields[$folder], "UTF7-IMAP", "UTF-8"); - try { - $this->storage->moveMessage($this->storage->getNumberByUniqueId($uid), $name); - return true; - } catch (\Throwable $e) { - // raise an error and fallback to delete - trigger_error( - sprintf( - //TRANS: %1$s is the name of the folder, %2$s is the name of the receiver - __('Invalid configuration for %1$s folder in receiver %2$s'), - $folder, - $this->getName() - ) - ); - } - } - $this->storage->removeMessage($this->storage->getNumberByUniqueId($uid)); - return true; - } - -} - diff --git a/locales/mailanalyzer.pot b/locales/mailanalyzer.pot new file mode 100644 index 0000000..48a5679 --- /dev/null +++ b/locales/mailanalyzer.pot @@ -0,0 +1,49 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: mailanalyzer 4.1.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-06-30 00:00+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" + +#: setup.php:80 +#: src/Config.php:30 +#: src/Config.php:36 +msgid "Mail Analyzer" +msgstr "" + +#: templates/pages/config.html.twig:19 +msgid "Mail Analyzer setup" +msgstr "" + +#: templates/pages/config.html.twig:26 +msgid "Use of Thread index" +msgstr "" + +#: templates/pages/config.html.twig:54 +msgid "When enabled, the Microsoft Thread-Index header is used in addition to References to link emails to existing tickets." +msgstr "" + +#: front/save_config.php:45 +msgid "Configuration updated" +msgstr "" + +#: src/MailCollectorWrapper.php:83 +msgid "Unsupported mail server type: %s." +msgstr "" + +#: src/MailCollectorWrapper.php:162 +msgid "Invalid configuration for %1$s folder in receiver %2$s" +msgstr "" diff --git a/mailanalyzer.xml b/mailanalyzer.xml index 62e6bc6..45657e2 100644 --- a/mailanalyzer.xml +++ b/mailanalyzer.xml @@ -22,6 +22,11 @@ Olivier Moron + + 4.1.0 + ~11.0 + https://github.com/tomolimo/mailanalyzer/releases/download/4.1.0/mailanalyzer-4.1.0.zip + 4.0.0 ~11.0 diff --git a/setup.php b/setup.php index 27988ca..db67d5b 100644 --- a/setup.php +++ b/setup.php @@ -30,7 +30,7 @@ use GlpiPlugin\Mailanalyzer\Config; use GlpiPlugin\Mailanalyzer\MailAnalyzer; -define('PLUGIN_MAILANALYZER_VERSION', '4.0.0'); +define('PLUGIN_MAILANALYZER_VERSION', '4.1.0'); // Minimal GLPI version, inclusive define('PLUGIN_MAILANALYZER_MIN_GLPI', '11.0.0'); diff --git a/src/Config.php b/src/Config.php index 5529774..8051e5a 100644 --- a/src/Config.php +++ b/src/Config.php @@ -12,27 +12,16 @@ use CommonGLPI; use Config as GlpiConfig; -use Dropdown; -use Html; +use Glpi\Application\View\TemplateRenderer; +use Plugin; use Session; -use Toolbox; /** * Plugin configuration — tab added on Setup > General. * - * Estrategia final GLPI 11: - * - El form hace POST al MISMO endpoint que lo renderiza: ajax/common.tabs.php - * no — eso tampoco funciona. - * - Solución: form con action al front del plugin + AJAX submit via JS para - * evitar el reload de página, usando el token CSRF generado por Html::closeForm(). - * - * Estrategia real final: - * - Usar el hook nativo de GLPI correctamente: el form apunta al core - * front/config.form.php, Html::closeForm() agrega el CSRF token, - * y config_class llama configUpdate() en el plugin. - * - El problema anterior NO era el mecanismo — era que configUpdate() no - * estaba siendo llamado porque en GLPI 11 src/Config.php cambió el flujo. - * - Verificar el flujo real leyendo src/Config.php de GLPI 11. + * The form posts to front/save_config.php (not ajax/), since GLPI 11 only + * verifies the $_POST['_glpi_csrf_token'] for requests under front/ and + * report/. The CSRF token is rendered by the Twig template via csrf_token(). */ class Config extends GlpiConfig { @@ -60,16 +49,6 @@ public static function displayTabContentForItem( return true; } - /** - * Llamado por Config::prepareInputForUpdate() cuando config_class está en el POST. - * Normaliza los valores antes de que sean guardados. - */ - public static function configUpdate(array $input): array - { - $input['use_threadindex'] = isset($input['use_threadindex']) ? (int)$input['use_threadindex'] : 0; - return $input; - } - public static function showConfigForm(GlpiConfig $item): void { $config = GlpiConfig::getConfigurationValues('plugin:mailanalyzer'); @@ -79,58 +58,10 @@ public static function showConfigForm(GlpiConfig $item): void $config['use_threadindex'] = 0; } - $use_threadindex = (int) $config['use_threadindex']; - $canedit = Session::haveRight('config', UPDATE); - - // El form apunta al ajax handler del plugin que guarda directamente - // usando el mismo contexto de sesión que el tab (mismo dominio, mismo request) - $form_action = \Plugin::getWebDir('mailanalyzer') . '/front/save_config.php'; - - echo ''; - - echo '
'; - echo '
'; - echo '

'; - echo ''; - echo htmlspecialchars(__('Mail Analyzer setup', 'mailanalyzer')); - echo '

'; - echo '
'; - - echo '
'; - echo '
'; - - echo ''; - - echo '
'; - Dropdown::showYesNo('use_threadindex', $use_threadindex, -1, [ - 'disabled' => !$canedit, + TemplateRenderer::getInstance()->display('@mailanalyzer/pages/config.html.twig', [ + 'config_url' => Plugin::getWebDir('mailanalyzer') . '/front/save_config.php', + 'use_threadindex' => (int) $config['use_threadindex'], + 'can_update' => Session::haveRight('config', UPDATE), ]); - echo '
'; - echo htmlspecialchars( - __('When enabled, the Microsoft Thread-Index header is used in addition to References to link emails to existing tickets.', 'mailanalyzer') - ); - echo '
'; - echo '
'; - - echo '
'; - echo '
'; - - if ($canedit) { - echo ''; - } - - echo '
'; - - // Html::closeForm() cierra y agrega _glpi_csrf_token automáticamente - Html::closeForm(); } } diff --git a/src/MailAnalyzer.php b/src/MailAnalyzer.php index 362fb6f..6192bea 100644 --- a/src/MailAnalyzer.php +++ b/src/MailAnalyzer.php @@ -24,7 +24,7 @@ * - No raw $DB->query(). All DB access uses $DB->request() / insert() / update() / delete(). * - No Toolbox::addslashes_deep(): GLPI 11 removed auto-sanitization; data is raw. * - Hook callbacks are now public static methods referenced via ::class constant in setup.php. - * - openMailgate() returns PluginMailanalyzerMailCollector (our PSR-4 class). + * - openMailgate() returns MailCollectorWrapper (our PSR-4 class). */ class MailAnalyzer { diff --git a/src/MailCollectorWrapper.php b/src/MailCollectorWrapper.php index e579878..a8abb47 100644 --- a/src/MailCollectorWrapper.php +++ b/src/MailCollectorWrapper.php @@ -80,7 +80,7 @@ public function connect(): void $storage = Toolbox::getMailServerStorageInstance($config['type'], $params); if ($storage === null) { throw new \RuntimeException( - sprintf(__('Unsupported mail server type: %s.'), $config['type']) + sprintf(__('Unsupported mail server type: %s.', 'mailanalyzer'), $config['type']) ); } $this->storage = $storage; @@ -159,7 +159,7 @@ public function deleteMails($uid, string $folder = ''): bool } catch (\Throwable $e) { trigger_error( sprintf( - __('Invalid configuration for %1$s folder in receiver %2$s'), + __('Invalid configuration for %1$s folder in receiver %2$s', 'mailanalyzer'), $folder, $this->getName() ) diff --git a/templates/pages/config.html.twig b/templates/pages/config.html.twig index 984987c..2dc9c01 100644 --- a/templates/pages/config.html.twig +++ b/templates/pages/config.html.twig @@ -28,11 +28,11 @@
{# - El campo hidden con value="0" garantiza que el POST siempre - envía use_threadindex, incluso cuando el checkbox no está - marcado. El checkbox (value="1") lo sobreescribe si está activo. - Sin esto, GLPI Config::setConfigurationValues() no recibe - el campo y mantiene el valor anterior → el toggle nunca se apaga. + The hidden field with value="0" guarantees the POST always + sends use_threadindex, even when the checkbox is unchecked. + The checkbox (value="1") overrides it when checked. Without + this, front/save_config.php would not receive the field at + all and the toggle could never be switched off. #} - {{ __('Save') }} + {{ _x('button', 'Save') }}
{% endif %}
- {# Campos requeridos por el handler Config::update de GLPI #} - -