dolibarr 25.0.0-alpha
email_cleaner.class.php
Go to the documentation of this file.
1<?php
2/* Copyright (C) 2026 Braito <braito4@hotmail.com>
3 * Copyright (C) 2026 MDW <mdeweerd@users.noreply.github.com>
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 3 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 */
18
29{
30 private const TABLE_AI_REQUEST_LOG = 'ai_request_log';
31 private const TOOL_NAME = 'email_cleaner';
32
36 private $tableExistsCache = array();
37
43 public function getDefinitions(): array
44 {
45 if (!isModEnabled('emailcollector') || !getDolGlobalInt('AI_EMAILCLEANER_ENABLED', 0)) {
46 return [];
47 }
48
49 return [
50 [
51 'name' => 'list_email_cleaner_runs',
52 'description' => 'List EmailCollector AI cleaner runs logged in the shared AI request log. Technical metadata only, no business interpretation.',
53 'inputSchema' => [
54 'type' => 'object',
55 'properties' => [
56 'actioncomm_id' => ['type' => 'integer', 'description' => 'Optional agenda event id filter.'],
57 'collector_id' => ['type' => 'integer', 'description' => 'Optional collector id filter.'],
58 'message_id' => ['type' => 'string', 'description' => 'Optional message-id filter.'],
59 'context_profile_code' => ['type' => 'string', 'description' => 'Optional context profile filter.'],
60 'min_confidence' => ['type' => 'number', 'description' => 'Optional minimum cleaning confidence (0..1).'],
61 'max_confidence' => ['type' => 'number', 'description' => 'Optional maximum cleaning confidence (0..1).'],
62 'limit' => ['type' => 'integer', 'default' => 20],
63 'offset' => ['type' => 'integer', 'default' => 0],
64 ],
65 ],
66 ],
67 [
68 'name' => 'get_email_cleaner_run',
69 'description' => 'Get one EmailCollector AI cleaner run from the shared AI request log.',
70 'inputSchema' => [
71 'type' => 'object',
72 'properties' => [
73 'ai_request_log_id' => ['type' => 'integer', 'description' => 'AI request log rowid.'],
74 'cleaning_id' => ['type' => 'integer', 'description' => 'Backward-compatible alias for ai_request_log_id.'],
75 'message_id' => ['type' => 'string', 'description' => 'Message-id fallback selector.'],
76 'collector_id' => ['type' => 'integer', 'description' => 'Optional collector filter when using message_id.'],
77 ],
78 'anyOf' => [
79 ['required' => ['ai_request_log_id']],
80 ['required' => ['cleaning_id']],
81 ['required' => ['message_id']],
82 ],
83 ],
84 ],
85 [
86 'name' => 'get_email_thread_context',
87 'description' => 'Get only the technical email thread context extracted by EmailCleaner. No business interpretation.',
88 'inputSchema' => [
89 'type' => 'object',
90 'properties' => [
91 'ai_request_log_id' => ['type' => 'integer', 'description' => 'AI request log rowid.'],
92 'cleaning_id' => ['type' => 'integer', 'description' => 'Backward-compatible alias for ai_request_log_id.'],
93 'message_id' => ['type' => 'string', 'description' => 'Message-id fallback selector.'],
94 'collector_id' => ['type' => 'integer', 'description' => 'Optional collector filter when using message_id.'],
95 ],
96 'anyOf' => [
97 ['required' => ['ai_request_log_id']],
98 ['required' => ['cleaning_id']],
99 ['required' => ['message_id']],
100 ],
101 ],
102 ],
103 [
104 'name' => 'get_email_handoff_payload',
105 'description' => 'Get handoff payload generated by EmailCleaner from the shared AI request log. Evidence only, no decision.',
106 'inputSchema' => [
107 'type' => 'object',
108 'properties' => [
109 'ai_request_log_id' => ['type' => 'integer', 'description' => 'AI request log rowid.'],
110 'handoff_id' => ['type' => 'integer', 'description' => 'Backward-compatible alias for ai_request_log_id.'],
111 'cleaning_id' => ['type' => 'integer', 'description' => 'Backward-compatible alias for ai_request_log_id.'],
112 ],
113 'anyOf' => [
114 ['required' => ['ai_request_log_id']],
115 ['required' => ['handoff_id']],
116 ['required' => ['cleaning_id']],
117 ],
118 ],
119 ],
120 ];
121 }
122
128 public function getCategories(): array
129 {
130 return ['global'];
131 }
132
140 public function execute(string $name, array $args)
141 {
142 if (!$this->canReadEmailData()) {
143 return $this->appendComplianceMetadata(['error' => "Permission Denied: You don't have rights to read email cleaner data."]);
144 }
145
146 $result = array();
147 switch ($name) {
148 case 'list_email_cleaner_runs':
149 $result = $this->listEmailCleanerRuns($args);
150 break;
151
152 case 'get_email_cleaner_run':
153 $result = $this->getEmailCleanerRun($args);
154 break;
155
156 case 'get_email_thread_context':
157 $result = $this->getEmailThreadContext($args);
158 break;
159
160 case 'get_email_handoff_payload':
161 $result = $this->getEmailHandoffPayload($args);
162 break;
163
164 default:
165 $result = ['error' => "Tool function '$name' not found."];
166 break;
167 }
168
169 return $this->appendComplianceMetadata($result);
170 }
171
177 private function canReadEmailData(): bool
178 {
179 if (empty($this->user->id)) {
180 return false;
181 }
182 if (!is_object($this->conf) || !isModEnabled('ai')) {
183 return false;
184 }
185 if (!isModEnabled('emailcollector')) {
186 return false;
187 }
188 if (!getDolGlobalInt('AI_EMAILCLEANER_ENABLED', 0)) {
189 return false;
190 }
191 if (!empty($this->user->admin)) {
192 return true;
193 }
194 if (method_exists($this->user, 'hasRight') && $this->user->hasRight('emailcollector', 'read')) {
195 return true;
196 }
197 return false;
198 }
199
206 private function listEmailCleanerRuns(array $args): array
207 {
208 if (!$this->isTableAvailable(self::TABLE_AI_REQUEST_LOG)) {
209 return ['error' => 'AI request log table is not available.'];
210 }
211
212 $entity = (int) (!empty($this->conf->entity) ? $this->conf->entity : 1);
213 $limit = $this->sanitizeLimit($args['limit'] ?? 20);
214 $offset = max(0, (int) ($args['offset'] ?? 0));
215 $actioncommId = (int) ($args['actioncomm_id'] ?? 0);
216 $collectorId = (int) ($args['collector_id'] ?? 0);
217 $messageId = trim((string) ($args['message_id'] ?? ''));
218 $contextProfileCode = trim((string) ($args['context_profile_code'] ?? ''));
219 $minConfidence = $this->sanitizeConfidenceOrNull($args['min_confidence'] ?? null);
220 $maxConfidence = $this->sanitizeConfidenceOrNull($args['max_confidence'] ?? null);
221
222 $sql = "SELECT rowid, fk_actioncomm, provider, confidence, status, raw_request_payload, date_request";
223 $sql .= " FROM ".MAIN_DB_PREFIX.self::TABLE_AI_REQUEST_LOG;
224 $sql .= " WHERE entity = ".((int) $entity);
225 $sql .= " AND tool_name = '".$this->db->escape(self::TOOL_NAME)."'";
226
227 if ($actioncommId > 0) {
228 $sql .= " AND fk_actioncomm = ".((int) $actioncommId);
229 }
230 if ($collectorId > 0) {
231 $sql .= " AND raw_request_payload LIKE '%\"collector_id\":".((int) $collectorId)."%'";
232 }
233 if ($messageId !== '') {
234 $sql .= " AND raw_request_payload LIKE '%".$this->db->escape($messageId)."%'";
235 }
236 if ($contextProfileCode !== '') {
237 $sql .= " AND raw_request_payload LIKE '%".$this->db->escape($contextProfileCode)."%'";
238 }
239 if ($minConfidence !== null) {
240 $sql .= " AND confidence >= ".((float) $minConfidence);
241 }
242 if ($maxConfidence !== null) {
243 $sql .= " AND confidence <= ".((float) $maxConfidence);
244 }
245
246 $sql .= " ORDER BY rowid DESC";
247 $sql .= $this->db->plimit($limit, $offset);
248
249 $resql = $this->db->query($sql);
250 if (!$resql) {
251 return ['error' => 'Database error while listing email cleaner runs: '.$this->db->lasterror()];
252 }
253
254 $items = array();
255 while ($obj = $this->db->fetch_object($resql)) {
256 $inputMetadata = $this->decodeJsonOrRaw((string) $obj->raw_request_payload);
257 $items[] = array(
258 'ai_request_log_id' => (int) $obj->rowid,
259 'cleaning_id' => (int) $obj->rowid,
260 'actioncomm_id' => (int) $obj->fk_actioncomm,
261 'collector_id' => (!empty($inputMetadata['collector_id']) ? (int) $inputMetadata['collector_id'] : null),
262 'message_id' => (!empty($inputMetadata['message_id']) ? (string) $inputMetadata['message_id'] : null),
263 'cleaning_confidence' => (float) $obj->confidence,
264 'cleaning_model' => null,
265 'prompt_code' => (!empty($inputMetadata['prompt_code']) ? (string) $inputMetadata['prompt_code'] : null),
266 'prompt_version' => (!empty($inputMetadata['prompt_version']) ? (string) $inputMetadata['prompt_version'] : null),
267 'context_profile_code' => (!empty($inputMetadata['context_profile_code']) ? (string) $inputMetadata['context_profile_code'] : null),
268 'context_profile_version' => (!empty($inputMetadata['context_profile_version']) ? (string) $inputMetadata['context_profile_version'] : null),
269 'status' => (!empty($obj->status) ? (string) $obj->status : null),
270 'date_creation' => (!empty($obj->date_request) ? (string) $obj->date_request : null),
271 );
272 }
273 $this->db->free($resql);
274
275 return array(
276 'items' => $items,
277 'count' => count($items),
278 'filters' => array(
279 'entity' => $entity,
280 'actioncomm_id' => ($actioncommId > 0 ? $actioncommId : null),
281 'collector_id' => ($collectorId > 0 ? $collectorId : null),
282 'message_id' => ($messageId !== '' ? $messageId : null),
283 'context_profile_code' => ($contextProfileCode !== '' ? $contextProfileCode : null),
284 'min_confidence' => $minConfidence,
285 'max_confidence' => $maxConfidence,
286 'limit' => $limit,
287 'offset' => $offset,
288 ),
289 );
290 }
291
298 private function getEmailCleanerRun(array $args): array
299 {
300 if (!$this->isTableAvailable(self::TABLE_AI_REQUEST_LOG)) {
301 return ['error' => 'AI request log table is not available.'];
302 }
303
304 $entity = (int) (!empty($this->conf->entity) ? $this->conf->entity : 1);
305 $aiRequestLogId = (int) ($args['ai_request_log_id'] ?? ($args['cleaning_id'] ?? 0));
306 $messageId = trim((string) ($args['message_id'] ?? ''));
307 $collectorId = (int) ($args['collector_id'] ?? 0);
308
309 $sql = "SELECT rowid, entity, fk_actioncomm, provider, input_hash, output_hash, security_hash, confidence, status, raw_request_payload, raw_response_payload, date_request";
310 $sql .= " FROM ".MAIN_DB_PREFIX.self::TABLE_AI_REQUEST_LOG;
311 $sql .= " WHERE entity = ".((int) $entity);
312 $sql .= " AND tool_name = '".$this->db->escape(self::TOOL_NAME)."'";
313
314 if ($aiRequestLogId > 0) {
315 $sql .= " AND rowid = ".((int) $aiRequestLogId);
316 } elseif ($messageId !== '') {
317 $sql .= " AND raw_request_payload LIKE '%".$this->db->escape($messageId)."%'";
318 if ($collectorId > 0) {
319 $sql .= " AND raw_request_payload LIKE '%\"collector_id\":".((int) $collectorId)."%'";
320 }
321 $sql .= " ORDER BY rowid DESC";
322 } else {
323 return ['error' => "Missing selector: use 'ai_request_log_id', 'cleaning_id' or 'message_id'."];
324 }
325
326 $sql .= $this->db->plimit(1);
327
328 $resql = $this->db->query($sql);
329 if (!$resql) {
330 return ['error' => 'Database error while reading cleaner run: '.$this->db->lasterror()];
331 }
332 $obj = $this->db->fetch_object($resql);
333 $this->db->free($resql);
334 if (!$obj) {
335 return ['error' => 'Email cleaner run not found.'];
336 }
337
338 $inputMetadata = $this->decodeJsonOrRaw((string) $obj->raw_request_payload);
339 $outputJson = $this->decodeJsonOrRaw((string) $obj->raw_response_payload);
340 $handoffJson = (!empty($outputJson['handoff_payload_json']) && is_array($outputJson['handoff_payload_json'])) ? $outputJson['handoff_payload_json'] : array();
341
342 return array(
343 'ai_request_log_id' => (int) $obj->rowid,
344 'cleaning_id' => (int) $obj->rowid,
345 'entity' => (int) $obj->entity,
346 'actioncomm_id' => (int) $obj->fk_actioncomm,
347 'collector_id' => (!empty($inputMetadata['collector_id']) ? (int) $inputMetadata['collector_id'] : 0),
348 'message_id' => (!empty($inputMetadata['message_id']) ? (string) $inputMetadata['message_id'] : ''),
349 'raw_hash' => (!empty($inputMetadata['raw_hash']) ? (string) $inputMetadata['raw_hash'] : null),
350 'clean_hash' => (!empty($inputMetadata['clean_hash']) ? (string) $inputMetadata['clean_hash'] : null),
351 'clean_body' => (!empty($outputJson['clean_body']) ? (string) $outputJson['clean_body'] : ''),
352 'cleaning_confidence' => (float) $obj->confidence,
353 'cleaning_model' => (!empty($outputJson['cleaning_model']) ? (string) $outputJson['cleaning_model'] : null),
354 'prompt_code' => (!empty($inputMetadata['prompt_code']) ? (string) $inputMetadata['prompt_code'] : null),
355 'prompt_version' => (!empty($inputMetadata['prompt_version']) ? (string) $inputMetadata['prompt_version'] : null),
356 'context_profile_code' => (!empty($inputMetadata['context_profile_code']) ? (string) $inputMetadata['context_profile_code'] : null),
357 'context_profile_version' => (!empty($inputMetadata['context_profile_version']) ? (string) $inputMetadata['context_profile_version'] : null),
358 'input_hash' => (!empty($obj->input_hash) ? (string) $obj->input_hash : null),
359 'output_hash' => (!empty($obj->output_hash) ? (string) $obj->output_hash : null),
360 'security_hash' => (!empty($obj->security_hash) ? (string) $obj->security_hash : null),
361 'status' => (!empty($obj->status) ? (string) $obj->status : null),
362 'raw_request_payload' => $inputMetadata,
363 'cleaning_json' => $outputJson,
364 'handoff_payload_json' => $handoffJson,
365 'date_creation' => (!empty($obj->date_request) ? (string) $obj->date_request : null),
366 );
367 }
368
375 private function getEmailThreadContext(array $args): array
376 {
377 $run = $this->getEmailCleanerRun($args);
378 if (!empty($run['error'])) {
379 return $run;
380 }
381
382 $cleaningJson = (!empty($run['cleaning_json']) && is_array($run['cleaning_json'])) ? $run['cleaning_json'] : array();
383 $handoffJson = (!empty($run['handoff_payload_json']) && is_array($run['handoff_payload_json'])) ? $run['handoff_payload_json'] : array();
384 $emailContext = array();
385
386 if (!empty($cleaningJson['email_context']) && is_array($cleaningJson['email_context'])) {
387 $emailContext = $cleaningJson['email_context'];
388 } elseif (!empty($handoffJson['conversation_context']) && is_array($handoffJson['conversation_context'])) {
389 $emailContext = $handoffJson['conversation_context'];
390 }
391
392 return array(
393 'ai_request_log_id' => (int) $run['ai_request_log_id'],
394 'cleaning_id' => (int) $run['cleaning_id'],
395 'actioncomm_id' => (int) $run['actioncomm_id'],
396 'collector_id' => (int) $run['collector_id'],
397 'message_id' => (string) $run['message_id'],
398 'email_context' => $emailContext,
399 'cleaning_confidence' => (float) $run['cleaning_confidence'],
400 'context_profile_code' => (!empty($run['context_profile_code']) ? (string) $run['context_profile_code'] : null),
401 'context_profile_version' => (!empty($run['context_profile_version']) ? (string) $run['context_profile_version'] : null),
402 );
403 }
404
411 private function getEmailHandoffPayload(array $args): array
412 {
413 $aiRequestLogId = (int) ($args['ai_request_log_id'] ?? ($args['handoff_id'] ?? ($args['cleaning_id'] ?? 0)));
414 if ($aiRequestLogId <= 0) {
415 return ['error' => "Missing selector: use 'ai_request_log_id', 'handoff_id' or 'cleaning_id'."];
416 }
417
418 $run = $this->getEmailCleanerRun(array('ai_request_log_id' => $aiRequestLogId));
419 if (!empty($run['error'])) {
420 return $run;
421 }
422
423 return array(
424 'ai_request_log_id' => (int) $run['ai_request_log_id'],
425 'handoff_id' => (int) $run['ai_request_log_id'],
426 'cleaning_id' => (int) $run['cleaning_id'],
427 'actioncomm_id' => (int) $run['actioncomm_id'],
428 'handoff_version' => (!empty($run['handoff_payload_json']['handoff_version']) ? (string) $run['handoff_payload_json']['handoff_version'] : null),
429 'consumer_code' => 'generic',
430 'payload_json' => $run['handoff_payload_json'],
431 'payload_hash' => (!empty($run['output_hash']) ? (string) $run['output_hash'] : null),
432 'quality_status' => (!empty($run['status']) ? (string) $run['status'] : null),
433 'date_creation' => (!empty($run['date_creation']) ? (string) $run['date_creation'] : null),
434 );
435 }
436
443 private function isTableAvailable(string $tableWithoutPrefix): bool
444 {
445 if (isset($this->tableExistsCache[$tableWithoutPrefix])) {
446 return $this->tableExistsCache[$tableWithoutPrefix];
447 }
448
449 $full = MAIN_DB_PREFIX.$tableWithoutPrefix;
450 $ok = (bool) count($this->db->DDLInfoTable($full));
451 $this->tableExistsCache[$tableWithoutPrefix] = $ok;
452
453 return $ok;
454 }
455
462 private function decodeJsonOrRaw(string $raw)
463 {
464 $raw = trim($raw);
465 if ($raw === '') {
466 return array();
467 }
468 $dec = json_decode($raw, true);
469 if (json_last_error() === JSON_ERROR_NONE && is_array($dec)) {
470 return $dec;
471 }
472 return $raw;
473 }
474
481 private function sanitizeLimit($raw): int
482 {
483 $limit = (int) $raw;
484 if ($limit <= 0) {
485 $limit = 20;
486 }
487 if ($limit > 200) {
488 $limit = 200;
489 }
490 return $limit;
491 }
492
499 private function sanitizeConfidenceOrNull($raw): ?float
500 {
501 if ($raw === null || $raw === '') {
502 return null;
503 }
504 $val = (float) $raw;
505 if ($val < 0) {
506 $val = 0;
507 }
508 if ($val > 1) {
509 $val = 1;
510 }
511 return $val;
512 }
513
520 private function appendComplianceMetadata(array $result): array
521 {
522 $result['compliance'] = array(
523 'ai_transparency_label' => 'AI-derived technical evidence',
524 'human_review_required' => 1,
525 'autonomous_business_action_allowed' => 0,
526 'policy_scope' => 'diagnostic_only_no_business_decision',
527 );
528
529 return $result;
530 }
531}
Abstract base class for all MCP (Model Context Protocol) tools.
Class ToolEmailCleaner.
decodeJsonOrRaw(string $raw)
Decode json string, fallback to raw.
sanitizeLimit($raw)
Sanitize limit.
appendComplianceMetadata(array $result)
Append compliance disclosure to MCP responses.
sanitizeConfidenceOrNull($raw)
Sanitize confidence value or return null.
isTableAvailable(string $tableWithoutPrefix)
Check if a table is available.
getEmailCleanerRun(array $args)
Get one cleaner run.
getEmailHandoffPayload(array $args)
Get handoff payload.
listEmailCleanerRuns(array $args)
List cleaner runs.
getDefinitions()
Returns tool definitions.
canReadEmailData()
Check read permission for technical email data.
getEmailThreadContext(array $args)
Get only the technical email thread context from a cleaner run.
getCategories()
Return categories this tool belongs to.
execute(string $name, array $args)
Execute tool.
getDolGlobalInt($key, $default=0)
Return a Dolibarr global constant int value.
isModEnabled($module)
Is Dolibarr module enabled.
conf($dolibarr_main_document_root, $realpathconf=null)
Load conf file (file must exists)
Definition inc.php:431
$conf db user
Active Directory does not allow anonymous connections.
Definition repair.php:141