diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..d6443b1
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,69 @@
+# Changelog
+
+## [4.1.0] — 2026-07-03
+
+- 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
+- Rebased onto upstream master (4.0.1/4.0.2), carrying forward:
+ - `message_id` column widened from `VARCHAR(255)` to `VARCHAR(512)`
+ - Schema upgrade steps in `plugin_mailanalyzer_install()` now gated behind a stored `dbversion`, so they only run once instead of on every activation/update
+ - Fixed a version-bounds check in `plugin_mailanalyzer_check_prerequisites()` that could never trigger (`&&` of two mutually exclusive conditions, now `||`)
+ - Default `use_threadindex` config is now only seeded when unset, so upgrading no longer silently resets a saved value back to 0
+
+## [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 55397a1..094e53d 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, 10.0 and 11.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.
+
+[](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.1.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 1d370e1..a8f61e3 100644
--- a/front/config.form.php
+++ b/front/config.form.php
@@ -3,33 +3,14 @@
-------------------------------------------------------------------------
MailAnalyzer plugin for GLPI
Copyright (C) 2011-2026 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: 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
new file mode 100644
index 0000000..69302f2
--- /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 203d197..459eff4 100644
--- a/hook.php
+++ b/hook.php
@@ -1,373 +1,159 @@
-.
---------------------------------------------------------------------------
- */
-/**
- * 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(512) 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());
- }
- }
-
- $dbversion = Config::getConfigurationValue('plugin:mailanalyzer', 'dbversion');
- if (is_null($dbversion)) {
- 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());
- }
-
- $fields = $DB->listFields('glpi_plugin_mailanalyzer_message_id', false);
- if (count($fields) > 0 && isset($fields['message_id']) && strtolower($fields['message_id']['Type']) === 'varchar(255)') {
- $query = "ALTER TABLE `glpi_plugin_mailanalyzer_message_id`
- CHANGE COLUMN `message_id` `message_id` VARCHAR(512) NOT NULL DEFAULT '0' COLLATE 'utf8mb4_unicode_ci' AFTER `id`;";
- $DB->doQuery($query) or die('Cannot alter glpi_plugin_mailanalyzer_message_id table to increase message_id!' . $DB->error());
- }
- Config::setConfigurationValues('plugin:mailanalyzer', ['dbversion' => '1.0.0']);
- }
-
- 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
- */
- public 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(512) 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}"
+ );
+
+ \Config::setConfigurationValues('plugin:mailanalyzer', [
+ 'dbversion' => PLUGIN_MAILANALYZER_DB_VERSION,
+ ]);
+ } elseif (\Config::getConfigurationValue('plugin:mailanalyzer', 'dbversion') !== PLUGIN_MAILANALYZER_DB_VERSION) {
+ // ── Upgrade path ──────────────────────────────────────────────────────
+ // Only re-run these (potentially expensive) schema fixes when the stored
+ // dbversion is behind, instead of on every install()/update() call.
+
+ // 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. Force UNSIGNED on tickets_id and mailcollectors_id.
+ //
+ // 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'";
+ }
+ 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}"
+ );
+ }
+
+ // 6. Widen message_id from VARCHAR(255) to VARCHAR(512) for older installs
+ // (some mail servers generate Message-ID / Thread-Index values longer than 255 chars).
+ $fields = $DB->listFields($table, false);
+ if (isset($fields['message_id']) && stripos($fields['message_id']['Type'], 'varchar(255)') !== false) {
+ $DB->doQueryOrDie(
+ "ALTER TABLE `{$table}` MODIFY `message_id` VARCHAR(512) NOT NULL DEFAULT '0'",
+ "Cannot widen message_id in {$table}"
+ );
+ }
+
+ $migration->executeMigration();
+
+ \Config::setConfigurationValues('plugin:mailanalyzer', [
+ 'dbversion' => PLUGIN_MAILANALYZER_DB_VERSION,
+ ]);
+ }
+
+ // Initialize default configuration only if it doesn't already exist, so an
+ // upgrade never resets a value the admin has already saved.
+ if (\Config::getConfigurationValue('plugin:mailanalyzer', 'use_threadindex') === null) {
+ \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/inc/config.class.php b/inc/config.class.php
deleted file mode 100644
index f80d5b9..0000000
--- a/inc/config.class.php
+++ /dev/null
@@ -1,111 +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 "