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. + +[![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.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 "
"; - 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 713703c..0000000 --- a/inc/mailcollector.class.php +++ /dev/null @@ -1,153 +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 8614097..73dd73c 100644 --- a/mailanalyzer.xml +++ b/mailanalyzer.xml @@ -1,101 +1,106 @@ - - - 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.2 - ~11.0.0 - https://github.com/tomolimo/mailanalyzer/releases/download/4.0.2/mailanalyzer-4.0.2.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 - - - 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.1.0 + ~11.0.0 + https://github.com/tomolimo/mailanalyzer/releases/download/4.1.0/mailanalyzer-4.1.0.zip + + + 4.0.2 + ~11.0.0 + https://github.com/tomolimo/mailanalyzer/releases/download/4.0.2/mailanalyzer-4.0.2.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 + + + 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 + + + diff --git a/setup.php b/setup.php index 3739d41..dfa9a23 100644 --- a/setup.php +++ b/setup.php @@ -1,117 +1,120 @@ -. --------------------------------------------------------------------------- - */ - -define("PLUGIN_MAILANALYZER_VERSION", "4.0.2"); -// 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; - } - return true; -} - - -/** - * 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.1.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: may print errors or add a message after redirect. + */ +function plugin_mailanalyzer_check_prerequisites(): bool +{ + 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; + } + 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..108ef3f --- /dev/null +++ b/src/Config.php @@ -0,0 +1,67 @@ + General. + * + * 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 +{ + 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; + } + + 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; + } + + 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), + ]); + } +} diff --git a/src/MailAnalyzer.php b/src/MailAnalyzer.php new file mode 100644 index 0000000..b59a93b --- /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 MailCollectorWrapper (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..89657fa --- /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.', 'mailanalyzer'), $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', 'mailanalyzer'), + $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..1984484 --- /dev/null +++ b/templates/pages/config.html.twig @@ -0,0 +1,74 @@ +{# + ------------------------------------------------------------------------- + MailAnalyzer plugin for GLPI — config.html.twig + Copyright (C) 2011-2026 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') }} +

+
+ +
+
+ +
+
+ {# + 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. + #} + + + +
+ + {{ __('When enabled, the Microsoft Thread-Index header is used in addition to References to link emails to existing tickets.', 'mailanalyzer') }} + +
+
+
+ + {% if can_update %} + + {% endif %} +
+ + + +