dolibarr 25.0.0-alpha
emailcleaner.class.php
Go to the documentation of this file.
1<?php
2/* Copyright (C) 2026 Braito <braito4@hotmail.com>
3 *
4 * This program is free software: you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License as published by
6 * the Free Software Foundation, either version 3 of the License, or
7 * (at your option) any later version.
8 */
9
16require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
17
22{
23 private const TABLE_AI_REQUEST_LOG = 'ai_request_log';
24
28 public $db;
29
35 public function __construct($db)
36 {
37 $this->db = $db;
38 }
39
45 public static function isRuntimeAvailable()
46 {
47 if (!isModEnabled('ai')) return false;
48 if (!isModEnabled('emailcollector')) return false;
49 return true;
50 }
51
58 public static function isEmailCollectorHookAction($action)
59 {
60 $actionCode = dol_strtolower(trim((string) $action));
61 if (strpos($actionCode, 'hook') !== 0) return false;
62 return in_array($actionCode, array('hook_ai_emailcleaner', 'hookaiemailcleaner'), true);
63 }
64
72 public function processEmailCollectorMessage($parameters, &$object)
73 {
74 global $conf, $dolibarr_main_data_root;
75
76
77 $rawBody = self::normalizeText((string) ($parameters['messagetext'] ?? ''));
78 $subject = (string) ($parameters['subject'] ?? '');
79 $header = (string) ($parameters['header'] ?? '');
80 // Email sender metadata; the SQL plugin treats variables named 'from' as SQL clauses.
81 // @phan-suppress-next-line SqlInjection
82 $from = (string) ($parameters['from'] ?? '');
83 $to = self::extractHeaderFieldValue($header, 'To');
84 $cc = self::extractHeaderFieldValue($header, 'Cc');
85 $replyTo = self::extractHeaderFieldValue($header, 'Reply-To');
86 $headerDate = self::extractHeaderFieldValue($header, 'Date');
87 $inReplyTo = self::extractHeaderFieldValue($header, 'In-Reply-To');
88 $referencesRaw = self::extractHeaderFieldValue($header, 'References');
89 $references = self::extractHeaderMessageIds($referencesRaw);
90 $collectorId = (!empty($object->id) ? (int) $object->id : 0);
91 $entity = (!empty($conf->entity) ? (int) $conf->entity : 1);
92 $isolatedMode = (int) getDolGlobalInt('AI_EMAILCLEANER_ISOLATED_MODE', 1);
93 $thirdpartyId = ($isolatedMode ? 0 : (!empty($parameters['thirdpartyid']) ? (int) $parameters['thirdpartyid'] : 0));
94 $objectId = ($isolatedMode ? 0 : (!empty($parameters['objectid']) ? (int) $parameters['objectid'] : 0));
95 $hasAttachments = (!empty($parameters['attachments']) && is_array($parameters['attachments']));
96 $actionParamRaw = (string) ($parameters['actionparam'] ?? '');
97 $actionParamCfg = self::parseActionParamConfig($actionParamRaw);
98 $contextProfileCode = trim((string) (!empty($actionParamCfg['context_profile_code']) ? $actionParamCfg['context_profile_code'] : ''));
99 $contextProfileVersion = trim((string) (!empty($actionParamCfg['context_profile_version']) ? $actionParamCfg['context_profile_version'] : ''));
100
101 if (trim($rawBody) === '') return array();
102
103 $msgid = self::extractMessageIdFromHeader($header);
104 if ($msgid === '') {
105 $msgid = sha1($subject."\n".$from."\n".dol_substr($rawBody, 0, 4000, '8bit'));
106 }
107 $messageScopes = self::buildMessageScopesBasic($rawBody, $subject, $from, $headerDate, $msgid, $inReplyTo, $references);
108 $replyOnly = (string) ($messageScopes['reply_only'] ?? '');
109 $quotedContext = (string) ($messageScopes['quoted_context'] ?? '');
110 $normalizedMessageId = self::normalizeMessageId($msgid);
111 $normalizedInReplyTo = self::normalizeMessageId($inReplyTo);
112 $normalizedReferences = self::normalizeMessageIdList($references);
113 $ancestorMessageIds = self::normalizeMessageIdList(array_merge($normalizedReferences, ($normalizedInReplyTo !== '' ? array($normalizedInReplyTo) : array())));
114 $emailContext = array(
115 'threading' => array(
116 'message_id' => ($normalizedMessageId !== '' ? $normalizedMessageId : $msgid),
117 'in_reply_to' => ($normalizedInReplyTo !== '' ? $normalizedInReplyTo : null),
118 'references' => $normalizedReferences,
119 'ancestor_message_ids' => $ancestorMessageIds,
120 'thread_depth' => count($ancestorMessageIds),
121 'thread_key' => self::buildThreadKey($normalizedMessageId, $normalizedInReplyTo, $normalizedReferences),
122 'link_type' => ($normalizedInReplyTo !== '' ? 'direct_reply' : (!empty($normalizedReferences) ? 'referenced_thread' : 'standalone')),
123 ),
124 'addresses' => array(
125 'from' => $from,
126 'to' => ($to !== '' ? $to : null),
127 'cc' => ($cc !== '' ? $cc : null),
128 'reply_to' => ($replyTo !== '' ? $replyTo : null),
129 ),
130 'meta' => array(
131 'date_header' => ($headerDate !== '' ? $headerDate : null),
132 'subject' => $subject,
133 'subject_normalized' => self::normalizeText($subject),
134 'collector_id' => $collectorId,
135 'thirdparty_id' => ($thirdpartyId > 0 ? $thirdpartyId : null),
136 'object_id' => ($objectId > 0 ? $objectId : null),
137 'has_attachments' => ($hasAttachments ? 1 : 0),
138 'isolated_mode' => ($isolatedMode ? 1 : 0),
139 ),
140 'body_context' => array(
141 'reply_only' => ($replyOnly !== '' ? $replyOnly : null),
142 'quoted_context' => ($quotedContext !== '' ? $quotedContext : null),
143 'has_quoted_context' => ($quotedContext !== '' ? 1 : 0),
144 'quoted_block_count' => count((array) ($messageScopes['quoted_blocks'] ?? array())),
145 'quoted_blocks' => (array) ($messageScopes['quoted_blocks'] ?? array()),
146 'parsing_warnings' => (array) ($messageScopes['parsing_warnings'] ?? array()),
147 ),
148 'timeline' => array(
149 'order' => 'current_then_quoted_newest_to_oldest',
150 'entries' => (array) ($messageScopes['timeline'] ?? array()),
151 ),
152 );
153 $attachmentRows = self::collectAttachmentRows(
154 (isset($parameters['attachments']) && is_array($parameters['attachments']) ? $parameters['attachments'] : array()),
155 (isset($parameters['savedattachments']) && is_array($parameters['savedattachments']) ? $parameters['savedattachments'] : array()),
156 (string) ($parameters['savedattachmentsdir'] ?? '')
157 );
158 $hasPdfAttachments = false;
159 foreach ($attachmentRows as $attRow) {
160 if (!empty($attRow['is_pdf'])) {
161 $hasPdfAttachments = true;
162 break;
163 }
164 }
165
166 $cleanResult = $this->runEmailCleaner($subject, $from, $rawBody);
167 $cleanedText = (string) ($cleanResult['clean_body'] ?? '');
168 $confidence = (float) ($cleanResult['confidence'] ?? 0.0);
169 $segments = (!empty($cleanResult['segments']) && is_array($cleanResult['segments'])) ? $cleanResult['segments'] : array();
170 $emailUnderstanding = (!empty($cleanResult['email_understanding']) && is_array($cleanResult['email_understanding'])) ? $cleanResult['email_understanding'] : self::buildFallbackEmailUnderstanding($cleanedText);
171 $engine = (string) ($cleanResult['engine'] ?? 'fallback');
172 $fallbackUsed = !empty($cleanResult['fallback_used']);
173 $hCleanup = self::buildNoiseSummaryFromSegments($segments);
174 $emailContext['body_context']['excluded_context_summary'] = $hCleanup;
175 $complianceMeta = self::buildComplianceMetadata($engine, $fallbackUsed, $confidence);
176 $pdfAttachmentItems = array();
177 if ($hasPdfAttachments) {
178 foreach ($attachmentRows as $attRow) {
179 if (empty($attRow['is_pdf'])) continue;
180 $pdfAttachmentItems[] = array(
181 'name' => (string) ($attRow['name'] ?? ''),
182 'relative_path' => (string) ($attRow['relative_path'] ?? ''),
183 'sha256' => (string) ($attRow['sha256'] ?? ''),
184 'detected_doc_type' => self::detectPdfDocTypeFromAttachmentName((string) ($attRow['name'] ?? '')),
185 'linking_target' => 'actioncomm',
186 'final_object_attachment_pending' => 1,
187 );
188 }
189 }
190 $handoffPayload = array(
191 'handoff_version' => 'emailcleaner_v2',
192 'email' => array(
193 'subject' => $subject,
194 'from' => $from,
195 'date' => ($headerDate !== '' ? $headerDate : null),
196 'raw_hash' => hash('sha256', $rawBody),
197 'message_id' => $msgid,
198 ),
199 'relevant_text' => array(
200 array(
201 'type' => 'main_content',
202 'text' => $cleanedText,
203 'confidence' => $confidence,
204 'source' => 'clean_body',
205 ),
206 ),
207 'email_understanding' => $emailUnderstanding,
208 'conversation_context' => $emailContext,
209 'supporting_evidence' => array(
210 'segment_count' => count($segments),
211 'fallback_used' => ($fallbackUsed ? 1 : 0),
212 'understanding_source' => ($engine === 'ai' ? 'ai_structured_preprocess' : 'fallback_clean_text'),
213 ),
214 'excluded_noise_summary' => $hCleanup,
215 'compliance' => $complianceMeta,
216 'needs_reprocessing' => ($fallbackUsed || $confidence < (float) getDolGlobalString('AI_EMAILCLEANER_MIN_CONFIDENCE', '0.60') ? 1 : 0),
217 'needs_pdf_linking' => ($hasPdfAttachments ? 1 : 0),
218 'pdf_attachments' => $pdfAttachmentItems,
219 );
220
221 $baseDir = (!empty($conf->ai->dir_output) ? $conf->ai->dir_output : $dolibarr_main_data_root.'/ai');
222 $storeDir = rtrim($baseDir, '/').'/emailcleaner/entity_'.$entity;
223 dol_mkdir($storeDir);
224
225 $safeKey = preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $msgid);
226 if ($safeKey === '' || $safeKey === null) $safeKey = sha1($msgid);
227 if (dol_strlen($safeKey) > 180) $safeKey = dol_substr($safeKey, 0, 180);
228
229 $payload = array(
230 'entity' => $entity,
231 'collector_id' => $collectorId,
232 'isolated_mode' => ($isolatedMode ? 1 : 0),
233 'message_id' => $msgid,
234 'email_from' => $from,
235 'email_subject' => $subject,
236 'raw_hash' => hash('sha256', $rawBody),
237 'clean_hash' => hash('sha256', $cleanedText),
238 'raw_body' => $rawBody,
239 'clean_body' => $cleanedText,
240 'segments' => $segments,
241 'cleaning_confidence' => $confidence,
242 'cleaning_model' => ($engine === 'ai' ? 'auto' : null),
243 'prompt_code' => 'email_cleaner_v2',
244 'prompt_version' => '2',
245 'context_profile_code' => ($contextProfileCode !== '' ? $contextProfileCode : null),
246 'context_profile_version' => ($contextProfileVersion !== '' ? $contextProfileVersion : null),
247 'engine' => $engine,
248 'fallback_used' => ($fallbackUsed ? 1 : 0),
249 'email_context' => $emailContext,
250 'email_understanding' => $emailUnderstanding,
251 'compliance' => $complianceMeta,
252 'handoff_payload_json' => $handoffPayload,
253 'date_cleaning_gmt' => dol_print_date(dol_now('gmt'), '%Y-%m-%d %H:%M:%S'),
254 );
255
256 $actioncommId = $this->findActionCommIdByMessageId($entity, $msgid);
257 $payload['fk_actioncomm'] = ($actioncommId > 0 ? $actioncommId : null);
258
259 $aiRequestLogId = $this->insertAiRequestLogRow(
260 $entity,
261 $actioncommId,
262 $collectorId,
263 $msgid,
264 $rawBody,
265 $cleanedText,
266 $segments,
267 $confidence,
268 $engine,
269 ($engine === 'ai' ? 'auto' : null),
270 'email_cleaner_v2',
271 '2',
272 $contextProfileCode,
273 $contextProfileVersion,
274 $emailContext,
275 $emailUnderstanding,
276 $handoffPayload,
277 $payload
278 );
279 if ($aiRequestLogId > 0) {
280 $payload['ai_request_log_id'] = $aiRequestLogId;
281 $handoffPayload['ai_request_log_id'] = $aiRequestLogId;
282 $payload['handoff_payload_json'] = $handoffPayload;
283 } elseif ($actioncommId <= 0) {
284 $payload['ai_request_log_status'] = 'not_persisted_no_event';
285 } elseif (!getDolGlobalInt('AI_LOG_REQUESTS')) {
286 $payload['ai_request_log_status'] = 'not_persisted_logging_disabled';
287 } else {
288 $payload['ai_request_log_status'] = 'not_persisted_storage_unavailable';
289 }
290
291 $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
292 if ($json !== false) {
293 $file = $storeDir.'/'.dol_sanitizeFileName($safeKey).'.json';
294 dol_syslog(__METHOD__.": save email cleaner output into ".$file, LOG_DEBUG);
295 @file_put_contents($file, $json);
296 $payload['file'] = $file;
297 }
298
299 return $payload;
300 }
301
309 private function findActionCommIdByMessageId($entity, $msgid)
310 {
311 $msgid = trim((string) $msgid);
312 if ($msgid === '') return 0;
313
314 $sql = "SELECT id";
315 $sql .= " FROM ".MAIN_DB_PREFIX."actioncomm";
316 $sql .= " WHERE entity = ".((int) $entity);
317 $sql .= " AND email_msgid = '".$this->db->escape($msgid)."'";
318 $sql .= " ORDER BY id DESC";
319 $sql .= $this->db->plimit(1);
320
321 $resql = $this->db->query($sql);
322 if (!$resql) return 0;
323 $obj = $this->db->fetch_object($resql);
324 $this->db->free($resql);
325
326 return ($obj && !empty($obj->id) ? (int) $obj->id : 0);
327 }
328
352 private function insertAiRequestLogRow($entity, $actioncommId, $collectorId, $msgid, $rawBody, $cleanedText, $segments, $confidence, $engine, $model, $promptCode, $promptVersion, $contextProfileCode, $contextProfileVersion, $emailContext, $emailUnderstanding, $handoffPayload, $payload)
353 {
354 global $user;
355
356 if ($actioncommId <= 0) return 0;
357 if (!getDolGlobalInt('AI_LOG_REQUESTS')) return 0;
358 if (!$this->isAiRequestLogTableAvailable()) return 0;
359
360 require_once DOL_DOCUMENT_ROOT.'/ai/lib/ai.lib.php';
361
362 $inputMetadata = array(
363 'source' => 'emailcollector',
364 'collector_id' => (int) $collectorId,
365 'message_id' => (string) $msgid,
366 'raw_hash' => hash('sha256', (string) $rawBody),
367 'clean_hash' => hash('sha256', (string) $cleanedText),
368 'prompt_code' => (string) $promptCode,
369 'prompt_version' => (string) $promptVersion,
370 'context_profile_code' => ($contextProfileCode !== '' ? (string) $contextProfileCode : null),
371 'context_profile_version' => ($contextProfileVersion !== '' ? (string) $contextProfileVersion : null),
372 );
373 $outputJson = array(
374 'clean_body' => $cleanedText,
375 'segments' => (is_array($segments) ? $segments : array()),
376 'cleaning_confidence' => (float) $confidence,
377 'cleaning_model' => ($model !== null && $model !== '' ? (string) $model : null),
378 'engine' => (string) $engine,
379 'collector_id' => (int) $collectorId,
380 'message_id' => (string) $msgid,
381 'context_profile_code' => ($contextProfileCode !== '' ? (string) $contextProfileCode : null),
382 'context_profile_version' => ($contextProfileVersion !== '' ? (string) $contextProfileVersion : null),
383 'email_context' => (is_array($emailContext) ? $emailContext : array()),
384 'email_understanding' => (is_array($emailUnderstanding) ? $emailUnderstanding : array()),
385 'handoff_payload_json' => (is_array($handoffPayload) ? $handoffPayload : array()),
386 'cleaner_payload' => (is_array($payload) ? $payload : array()),
387 );
388 $inputMetadataRaw = json_encode($inputMetadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
389 $outputRaw = json_encode($outputJson, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
390 if ($inputMetadataRaw === false || $outputRaw === false) return 0;
391
392 $inputHash = hash('sha256', (string) $msgid);
393 $outputHash = hash('sha256', $outputRaw);
394 $securityHash = hash('sha256', ((int) $entity).'|'.((int) $actioncommId).'|email_cleaner|'.$inputHash.'|'.$outputHash);
395 $minConfidence = (float) getDolGlobalString('AI_EMAILCLEANER_MIN_CONFIDENCE', '0.60');
396 if ($minConfidence <= 0 || $minConfidence > 1) $minConfidence = 0.60;
397 $status = ((float) $confidence >= $minConfidence ? 'Success' : 'LowConfidence');
398 if ((string) $engine === 'fallback') $status = 'Fallback';
399 $provider = ((string) $engine === 'ai' ? 'dolibarr_ai' : 'fallback');
400 // Plain log text, escaped by ai_log_request() when building the INSERT.
401 // @phan-suppress-next-line SqlInjection
402 $queryText = 'EmailCleaner message '.$msgid;
403
404 // Use the shared logger so the module logging policy also applies to collected emails.
405 $context = array(
406 'fk_actioncomm' => (int) $actioncommId,
407 'input_hash' => $inputHash,
408 'output_hash' => $outputHash,
409 'security_hash' => $securityHash,
410 'preserve_payloads' => true,
411 );
412 $logId = 0;
414 $this->db,
415 $user,
416 $queryText,
417 array('tool' => 'email_cleaner'),
418 $provider,
419 0.0,
420 (float) $confidence,
421 $status,
422 '',
423 $inputMetadataRaw,
424 $outputRaw,
425 $context,
426 $logId
427 );
428
429 return $logId;
430 }
431
438 {
439 static $available = null;
440 if ($available !== null) return $available;
441 $available = (bool) count($this->db->DDLInfoTable(MAIN_DB_PREFIX.self::TABLE_AI_REQUEST_LOG));
442 return $available;
443 }
444
451 public static function parseActionParamConfig($raw)
452 {
453 $raw = trim((string) $raw);
454 if ($raw === '') return array();
455 if ($raw[0] !== '{') return array('context_profile_code' => $raw);
456 $dec = json_decode($raw, true);
457 if (!is_array($dec)) return array();
458 return $dec;
459 }
460
467 public static function buildNoiseSummaryFromSegments($segments)
468 {
469 $out = array('counts' => array(), 'samples' => array());
470 if (empty($segments) || !is_array($segments)) return $out;
471 foreach ($segments as $seg) {
472 if (!is_array($seg)) continue;
473 $type = dol_strtolower(trim((string) ($seg['type'] ?? 'unknown')));
474 $text = trim((string) ($seg['text'] ?? ''));
475 if ($type === '') $type = 'unknown';
476 if (empty($out['counts'][$type])) $out['counts'][$type] = 0;
477 $out['counts'][$type]++;
478 if ($text !== '' && (empty($out['samples'][$type]) || !is_array($out['samples'][$type]))) $out['samples'][$type] = array();
479 if ($text !== '' && count($out['samples'][$type]) < 2) {
480 $out['samples'][$type][] = (dol_strlen($text) > 180 ? dol_substr($text, 0, 180) : $text);
481 }
482 }
483 return $out;
484 }
485
494 public static function buildComplianceMetadata($engine, $fallbackUsed, $confidence)
495 {
496 $minConfidence = (float) getDolGlobalString('AI_EMAILCLEANER_MIN_CONFIDENCE', '0.60');
497 if ($minConfidence <= 0 || $minConfidence > 1) $minConfidence = 0.60;
498
499 return array(
500 'ai_transparency_label' => 'AI-generated technical preprocessing output',
501 'human_review_required' => 1,
502 'autonomous_business_action_allowed' => 0,
503 'pii_redaction_enabled' => (getDolGlobalInt('AI_PRIVACY_REDACTION', 0) ? 1 : 0),
504 'ai_system_used' => ((string) $engine === 'ai' ? 1 : 0),
505 'fallback_used' => ($fallbackUsed ? 1 : 0),
506 'cleaning_confidence' => (float) $confidence,
507 'confidence_threshold' => $minConfidence,
508 'policy_scope' => 'diagnostic_only_no_business_decision',
509 );
510 }
511
520 public static function collectAttachmentRows($attachments, $savedAttachments, $savedDir = '')
521 {
522 $out = array();
523 $savedByName = array();
524 if (is_array($savedAttachments)) {
525 foreach ($savedAttachments as $sa) {
526 if (!is_array($sa)) continue;
527 $n = trim((string) ($sa['name'] ?? ''));
528 if ($n === '') continue;
529 $savedByName[dol_strtolower($n)] = $sa;
530 }
531 }
532
533 if (is_array($attachments)) {
534 foreach ($attachments as $att) {
535 $name = '';
536 $relative = '';
537 $sha = '';
538 if (is_object($att)) {
539 if (is_callable(array($att, 'getName'))) $name = (string) call_user_func(array($att, 'getName'));
540 if (is_callable(array($att, 'getPath'))) $relative = (string) call_user_func(array($att, 'getPath'));
541 if (is_callable(array($att, 'getContent'))) {
542 $content = (string) call_user_func(array($att, 'getContent'));
543 if ($content !== '') $sha = hash('sha256', $content);
544 }
545 } elseif (is_array($att)) {
546 $name = (string) (!empty($att['name']) ? $att['name'] : '');
547 $relative = (string) (!empty($att['path']) ? $att['path'] : '');
548 }
549 if ($name === '') continue;
550 $lookup = dol_strtolower($name);
551 if (isset($savedByName[$lookup]) && is_array($savedByName[$lookup])) {
552 $sa = $savedByName[$lookup];
553 if (!empty($sa['relative_path'])) $relative = (string) $sa['relative_path'];
554 if (!empty($sa['sha256'])) $sha = (string) $sa['sha256'];
555 }
556 if ($sha === '' && $relative !== '') {
557 $candidate = $relative;
558 if ($candidate[0] !== '/' && $savedDir !== '') $candidate = rtrim($savedDir, '/').'/'.$candidate;
559 if (@is_readable($candidate)) {
560 $h = @hash_file('sha256', $candidate);
561 if (is_string($h)) $sha = $h;
562 }
563 }
564 $ext = dol_strtolower(pathinfo($name, PATHINFO_EXTENSION));
565 $out[] = array(
566 'name' => $name,
567 'relative_path' => $relative,
568 'sha256' => $sha,
569 'is_pdf' => ($ext === 'pdf'),
570 );
571 }
572 }
573
574 if (empty($out) && is_array($savedAttachments)) {
575 foreach ($savedAttachments as $sa) {
576 if (!is_array($sa)) continue;
577 $name = trim((string) ($sa['name'] ?? ''));
578 if ($name === '') continue;
579 $ext = dol_strtolower(pathinfo($name, PATHINFO_EXTENSION));
580 $out[] = array(
581 'name' => $name,
582 'relative_path' => (string) ($sa['relative_path'] ?? ''),
583 'sha256' => (string) ($sa['sha256'] ?? ''),
584 'is_pdf' => ($ext === 'pdf'),
585 );
586 }
587 }
588
589 return $out;
590 }
591
598 public static function detectPdfDocTypeFromAttachmentName($filename)
599 {
600 $ext = dol_strtolower(pathinfo((string) $filename, PATHINFO_EXTENSION));
601 return ($ext === 'pdf' ? 'pdf_document' : 'unknown');
602 }
603
612 public function runEmailCleaner($subject, $from, $rawBody)
613 {
614 $fallbackCleanBody = self::fallbackCleanBody($rawBody);
615 $out = array(
616 'clean_body' => $fallbackCleanBody,
617 'confidence' => 0.40,
618 'segments' => array(),
619 'email_understanding' => self::buildFallbackEmailUnderstanding($fallbackCleanBody),
620 'engine' => 'fallback',
621 'fallback_used' => true,
622 );
623
624 require_once DOL_DOCUMENT_ROOT.'/ai/class/ai.class.php';
625 $ai = new Ai($this->db);
626 if (empty($ai->getApiService())) return $out;
627
628 $maxInput = (int) getDolGlobalInt('AI_EMAILCLEANER_MAX_INPUT', 16000);
629 if ($maxInput <= 1000) $maxInput = 16000;
630 $redactionEnabled = (int) getDolGlobalInt('AI_PRIVACY_REDACTION', 0);
631 $guard = null;
632 $fromForPrompt = $from;
633 $subjectForPrompt = $subject;
634 $bodyForPrompt = $rawBody;
635 if (dol_strlen($bodyForPrompt) > $maxInput) {
636 $bodyForPrompt = dol_substr($bodyForPrompt, 0, $maxInput);
637 }
638 if ($redactionEnabled) {
639 require_once DOL_DOCUMENT_ROOT.'/ai/class/privacy_guard.class.php';
640 if (class_exists('PrivacyGuard')) {
641 $guard = new PrivacyGuard();
642 $fromForPrompt = $guard->mask((string) $fromForPrompt);
643 $subjectForPrompt = $guard->mask((string) $subjectForPrompt);
644 $bodyForPrompt = $guard->mask((string) $bodyForPrompt);
645 }
646 }
647
648 $prompt = "You are an EmailCleaner and structured email preprocessor for ERP ingestion.\n";
649 $prompt .= "Task: separate current message content from quoted thread/noise and extract neutral understanding of the email.\n";
650 $prompt .= "Important constraints:\n";
651 $prompt .= "- Do NOT classify business intent and do NOT decide any action.\n";
652 $prompt .= "- Do NOT create, update, approve, reject, match, or route business objects.\n";
653 $prompt .= "- Extract only explicit information present in the email. Do not guess missing dates, amounts, references, contacts, or meaning.\n";
654 $prompt .= "- Be conservative: when unsure, keep text in clean_body.\n";
655 $prompt .= "- Put quoted/forwarded old thread content in segments, but keep only current-message facts in email_understanding.\n";
656 $prompt .= "- Return valid JSON only.\n";
657 $prompt .= "JSON schema:\n";
658 $prompt .= "{\n";
659 $prompt .= " \"clean_body\": string,\n";
660 $prompt .= " \"segments\": [\n";
661 $prompt .= " {\"type\":\"main_content|quoted_thread|signature|legal_disclaimer|environmental_notice|security_notice|social_footer|system_noise|unknown\",\"text\":string}\n";
662 $prompt .= " ],\n";
663 $prompt .= " \"email_understanding\": {\n";
664 $prompt .= " \"key_points\": string[],\n";
665 $prompt .= " \"standardized\": {\n";
666 $prompt .= " \"dates\": string[],\n";
667 $prompt .= " \"amounts\": string[],\n";
668 $prompt .= " \"references\": string[],\n";
669 $prompt .= " \"contacts\": string[],\n";
670 $prompt .= " \"languages\": string[]\n";
671 $prompt .= " },\n";
672 $prompt .= " \"categories\": {\n";
673 $prompt .= " \"topics\": string[],\n";
674 $prompt .= " \"referenced_items\": string[],\n";
675 $prompt .= " \"requested_operations\": string[],\n";
676 $prompt .= " \"risks_or_warnings\": string[],\n";
677 $prompt .= " \"noise_types\": string[]\n";
678 $prompt .= " },\n";
679 $prompt .= " \"structured\": {\n";
680 $prompt .= " \"primary_elements\": string[],\n";
681 $prompt .= " \"secondary_elements\": string[],\n";
682 $prompt .= " \"other_elements\": string[]\n";
683 $prompt .= " }\n";
684 $prompt .= " },\n";
685 $prompt .= " \"confidence\": number\n";
686 $prompt .= "}\n";
687 $prompt .= "Constraints:\n";
688 $prompt .= "- clean_body max 2000 chars.\n";
689 $prompt .= "- key_points max 8 items, each max 160 chars.\n";
690 $prompt .= "- every standardized/categories/structured list max 10 items, each max 180 chars.\n";
691 $prompt .= "Email metadata:\n";
692 $prompt .= "FROM: ".$fromForPrompt."\n";
693 $prompt .= "SUBJECT: ".$subjectForPrompt."\n";
694 $prompt .= "BODY:\n".$bodyForPrompt;
695
696 $generated = $ai->generateContent($prompt, 'auto', 'textgeneration', '');
697 if (is_array($generated) && !empty($generated['error'])) return $out;
698 if (!is_string($generated) || trim($generated) === '') return $out;
699 if ($guard) {
700 $generated = $guard->unmaskAiResponse($generated);
701 }
702
703 $decoded = self::decodeJsonFromAiString($generated);
704 if (empty($decoded) || !is_array($decoded)) return $out;
705
706 $cleanBody = self::normalizeText((string) ($decoded['clean_body'] ?? ''));
707 if ($cleanBody === '') return $out;
708
709 $confidence = (float) ($decoded['confidence'] ?? 0.0);
710 if ($confidence < 0) $confidence = 0;
711 if ($confidence > 1) $confidence = 1;
712
713 $minConfidence = (float) getDolGlobalString('AI_EMAILCLEANER_MIN_CONFIDENCE', '0.60');
714 if ($minConfidence <= 0 || $minConfidence > 1) $minConfidence = 0.60;
715
716 $segments = array();
717 if (!empty($decoded['segments']) && is_array($decoded['segments'])) {
718 foreach ($decoded['segments'] as $seg) {
719 if (!is_array($seg)) continue;
720 $type = trim((string) ($seg['type'] ?? 'unknown'));
721 $text = self::normalizeText((string) ($seg['text'] ?? ''));
722 if ($text === '') continue;
723 if ($type === '') $type = 'unknown';
724 $segments[] = array('type' => $type, 'text' => $text);
725 if (count($segments) >= 80) break;
726 }
727 }
728
729 if ($confidence < $minConfidence) {
730 // Conservative fallback: keep more context if confidence is low.
731 $out['clean_body'] = self::fallbackCleanBody($rawBody);
732 $out['confidence'] = $confidence;
733 $out['segments'] = $segments;
734 $out['email_understanding'] = self::sanitizeEmailUnderstanding($decoded['email_understanding'] ?? array(), $out['clean_body']);
735 $out['engine'] = 'ai_low_confidence_fallback';
736 $out['fallback_used'] = true;
737 return $out;
738 }
739
740 $out['clean_body'] = $cleanBody;
741 $out['confidence'] = $confidence;
742 $out['segments'] = $segments;
743 $out['email_understanding'] = self::sanitizeEmailUnderstanding($decoded['email_understanding'] ?? array(), $cleanBody);
744 $out['engine'] = 'ai';
745 $out['fallback_used'] = false;
746
747 return $out;
748 }
749
756 private static function buildFallbackEmailUnderstanding($cleanBody)
757 {
758 $cleanBody = self::normalizeText((string) $cleanBody);
759 $keyPoints = array();
760 if ($cleanBody !== '') {
761 $lines = preg_split("/\n/", $cleanBody);
762 if (is_array($lines)) {
763 foreach ($lines as $line) {
764 $line = trim((string) $line);
765 if ($line === '') continue;
766 $line = preg_replace('/\s+/', ' ', $line);
767 if (dol_strlen($line) > 160) $line = dol_substr($line, 0, 160);
768 $keyPoints[] = $line;
769 if (count($keyPoints) >= 5) break;
770 }
771 }
772 }
773
774 return array(
775 'key_points' => $keyPoints,
776 'standardized' => array(
777 'dates' => array(),
778 'amounts' => array(),
779 'references' => array(),
780 'contacts' => array(),
781 'languages' => array(),
782 ),
783 'categories' => array(
784 'topics' => array(),
785 'referenced_items' => array(),
786 'requested_operations' => array(),
787 'risks_or_warnings' => array(),
788 'noise_types' => array(),
789 ),
790 'structured' => array(
791 'primary_elements' => $keyPoints,
792 'secondary_elements' => array(),
793 'other_elements' => array(),
794 ),
795 );
796 }
797
805 private static function sanitizeEmailUnderstanding($raw, $fallbackCleanBody = '')
806 {
807 if (!is_array($raw)) {
808 return self::buildFallbackEmailUnderstanding($fallbackCleanBody);
809 }
810
812 $out['key_points'] = self::sanitizeStringList($raw['key_points'] ?? array(), 8, 160);
813
814 $standardized = (isset($raw['standardized']) && is_array($raw['standardized'])) ? $raw['standardized'] : array();
815 $out['standardized'] = array(
816 'dates' => self::sanitizeStringList($standardized['dates'] ?? array(), 10, 80),
817 'amounts' => self::sanitizeStringList($standardized['amounts'] ?? array(), 10, 80),
818 'references' => self::sanitizeStringList($standardized['references'] ?? array(), 10, 120),
819 'contacts' => self::sanitizeStringList($standardized['contacts'] ?? array(), 10, 160),
820 'languages' => self::sanitizeStringList($standardized['languages'] ?? array(), 5, 40),
821 );
822
823 $categories = (isset($raw['categories']) && is_array($raw['categories'])) ? $raw['categories'] : array();
824 $out['categories'] = array(
825 'topics' => self::sanitizeStringList($categories['topics'] ?? array(), 10, 160),
826 'referenced_items' => self::sanitizeStringList($categories['referenced_items'] ?? array(), 10, 160),
827 'requested_operations' => self::sanitizeStringList($categories['requested_operations'] ?? array(), 10, 160),
828 'risks_or_warnings' => self::sanitizeStringList($categories['risks_or_warnings'] ?? array(), 10, 180),
829 'noise_types' => self::sanitizeStringList($categories['noise_types'] ?? array(), 10, 80),
830 );
831
832 $structured = (isset($raw['structured']) && is_array($raw['structured'])) ? $raw['structured'] : array();
833 $out['structured'] = array(
834 'primary_elements' => self::sanitizeStringList($structured['primary_elements'] ?? array(), 12, 180),
835 'secondary_elements' => self::sanitizeStringList($structured['secondary_elements'] ?? array(), 12, 180),
836 'other_elements' => self::sanitizeStringList($structured['other_elements'] ?? array(), 12, 180),
837 );
838
839 if (empty($out['key_points']) && empty($out['structured']['primary_elements'])) {
840 return self::buildFallbackEmailUnderstanding($fallbackCleanBody);
841 }
842
843 return $out;
844 }
845
854 private static function sanitizeStringList($items, $maxItems, $maxLen)
855 {
856 $items = is_array($items) ? $items : array();
857 $maxItems = max(0, (int) $maxItems);
858 $maxLen = max(1, (int) $maxLen);
859 $out = array();
860 $seen = array();
861
862 foreach ($items as $item) {
863 $item = trim((string) $item);
864 if ($item === '' || dol_strtolower($item) === 'null') continue;
865 $item = preg_replace('/\s+/', ' ', $item);
866 $item = trim((string) $item);
867 if ($item === '') continue;
868 if (dol_strlen($item) > $maxLen) {
869 $item = dol_substr($item, 0, $maxLen);
870 $item = rtrim($item, " \t\r\n,;:");
871 }
872 $key = dol_strtolower($item);
873 if (!empty($seen[$key])) continue;
874 $seen[$key] = 1;
875 $out[] = $item;
876 if (count($out) >= $maxItems) break;
877 }
878
879 return $out;
880 }
881
888 public static function normalizeText($text)
889 {
890 $text = (string) $text;
891 $text = str_replace("\r\n", "\n", $text);
892 $text = str_replace("\r", "\n", $text);
893 return trim($text);
894 }
895
902 public static function extractMessageIdFromHeader($header)
903 {
904 $header = (string) $header;
905 if ($header === '') return '';
906 if (preg_match('/^Message-ID:\s*<([^>]+)>/mi', $header, $m)) {
907 return trim((string) $m[1]);
908 }
909 if (preg_match('/^Message-ID:\s*([^\s]+)/mi', $header, $m)) {
910 return trim(str_replace(array('<', '>'), '', (string) $m[1]));
911 }
912 return '';
913 }
914
922 public static function extractHeaderFieldValue($header, $fieldName)
923 {
924 $header = str_replace("\r\n", "\n", (string) $header);
925 $header = str_replace("\r", "\n", $header);
926 $fieldName = trim((string) $fieldName);
927 if ($header === '' || $fieldName === '') return '';
928
929 $re = '/^'.preg_quote($fieldName, '/').'\s*:\s*(.+)$/mi';
930 if (!preg_match($re, $header, $m, PREG_OFFSET_CAPTURE)) return '';
931
932 $val = (string) ($m[1][0] ?? '');
933 $offset = (int) ($m[1][1] ?? 0);
934 $after = dol_substr($header, $offset + dol_strlen($val, '8bit'), null, '8bit');
935 if ($after !== '') {
936 $lines = preg_split("/\n/", $after);
937 if (is_array($lines)) {
938 foreach ($lines as $ln) {
939 if ($ln === '') break;
940 if (preg_match('/^[ \t]+/', $ln)) {
941 $val .= ' '.trim($ln);
942 continue;
943 }
944 break;
945 }
946 }
947 }
948
949 return trim(str_replace(array('<', '>'), '', $val));
950 }
951
958 public static function extractHeaderMessageIds($raw)
959 {
960 $raw = trim((string) $raw);
961 if ($raw === '') return array();
962
963 $out = array();
964 if (preg_match_all('/<([^>]+)>/', $raw, $m)) {
965 foreach ((array) $m[1] as $v) {
966 $v = trim((string) $v);
967 if ($v === '') continue;
968 $out[] = $v;
969 }
970 }
971 if (empty($out)) {
972 $parts = preg_split('/\s+/', str_replace(array(',', ';'), ' ', $raw));
973 if (is_array($parts)) {
974 foreach ($parts as $p) {
975 $p = trim(str_replace(array('<', '>'), '', (string) $p));
976 if ($p === '' || strpos($p, '@') === false) continue;
977 $out[] = $p;
978 }
979 }
980 }
981
982 $out = array_values(array_unique($out));
983 if (count($out) > 20) $out = array_slice($out, -20);
984 return $out;
985 }
986
993 public static function normalizeMessageId($messageId)
994 {
995 $messageId = trim((string) $messageId);
996 $messageId = trim($messageId, "<> \t\n\r\0\x0B");
997 return dol_strtolower($messageId);
998 }
999
1006 public static function normalizeMessageIdList($messageIds)
1007 {
1008 $out = array();
1009 $seen = array();
1010 foreach ((array) $messageIds as $messageId) {
1011 $messageId = self::normalizeMessageId((string) $messageId);
1012 if ($messageId === '' || isset($seen[$messageId])) continue;
1013 $seen[$messageId] = true;
1014 $out[] = $messageId;
1015 }
1016 if (count($out) > 20) $out = array_slice($out, -20);
1017 return $out;
1018 }
1019
1032 public static function buildThreadKey($messageId, $inReplyTo, $references)
1033 {
1034 $references = self::normalizeMessageIdList($references);
1035 $root = (!empty($references) ? (string) $references[0] : self::normalizeMessageId($inReplyTo));
1036 if ($root === '') $root = self::normalizeMessageId($messageId);
1037 return ($root !== '' ? 'rfc822:'.hash('sha256', $root) : '');
1038 }
1039
1052 public static function buildMessageScopesBasic($body, $subject = '', $from = '', $date = '', $messageId = '', $inReplyTo = '', $references = array())
1053 {
1054 $body = self::normalizeText((string) $body);
1055 $replyOnly = self::extractReplyOnlyTextBasic($body);
1056 $quotedBlocks = self::extractQuotedMessageBlocksBasic($body, 16);
1057 $quotedParts = array();
1058 $timeline = array(array(
1059 'kind' => 'current_message',
1060 'index' => -1,
1061 'from' => (trim((string) $from) !== '' ? trim((string) $from) : null),
1062 'date_raw' => (trim((string) $date) !== '' ? trim((string) $date) : null),
1063 'subject' => (trim((string) $subject) !== '' ? trim((string) $subject) : null),
1064 'message_id' => (self::normalizeMessageId($messageId) !== '' ? self::normalizeMessageId($messageId) : null),
1065 'in_reply_to' => (self::normalizeMessageId($inReplyTo) !== '' ? self::normalizeMessageId($inReplyTo) : null),
1066 'references' => self::normalizeMessageIdList($references),
1067 ));
1068
1069 foreach ($quotedBlocks as $index => $block) {
1070 if (!is_array($block)) continue;
1071 $text = trim((string) ($block['text'] ?? ''));
1072 if ($text !== '') $quotedParts[] = $text;
1073 $timeline[] = array(
1074 'kind' => 'quoted_message',
1075 'index' => (int) $index,
1076 'from' => ($block['from'] ?? null),
1077 'date_raw' => ($block['date_raw'] ?? null),
1078 'subject' => ($block['subject'] ?? null),
1079 'message_id' => ($block['message_id'] ?? null),
1080 'in_reply_to' => ($block['in_reply_to'] ?? null),
1081 'references' => (array) ($block['references'] ?? array()),
1082 );
1083 }
1084
1085 $quotedContext = trim(implode("\n\n", $quotedParts));
1086 if ($quotedContext === '') $quotedContext = self::extractQuotedThreadSnippetBasic($body, 1200);
1087 if (dol_strlen($quotedContext) > 2400) $quotedContext = dol_substr($quotedContext, 0, 2400);
1088
1089 $warnings = array();
1090 if ($quotedContext !== '' && empty($quotedBlocks)) $warnings[] = 'quoted_context_without_structured_blocks';
1091
1092 return array(
1093 'schema_version' => 1,
1094 'parser' => 'emailcleaner_message_scopes_v1',
1095 'reply_only' => $replyOnly,
1096 'quoted_context' => $quotedContext,
1097 'quoted_blocks' => $quotedBlocks,
1098 'timeline' => $timeline,
1099 'parsing_warnings' => $warnings,
1100 );
1101 }
1102
1110 public static function extractQuotedMessageBlocksBasic($body, $maxBlocks = 16)
1111 {
1112 $body = self::normalizeText((string) $body);
1113 $maxBlocks = max(1, min(32, (int) $maxBlocks));
1114 if ($body === '') return array();
1115
1116 $lines = preg_split('/\n/', $body);
1117 if (!is_array($lines)) return array();
1118 $blocks = array();
1119 $current = array();
1120 $collecting = false;
1127 $flush = function (array &$blocks, array &$current, bool $collecting) use ($maxBlocks) {
1128 if (!$collecting || empty($current) || count($blocks) >= $maxBlocks) {
1129 $current = array();
1130 return;
1131 }
1132 $raw = trim(implode("\n", $current));
1133 $current = array();
1134 if ($raw === '') return;
1136 $text = trim((string) ($header['body'] ?? $raw));
1137 if ($text === '') return;
1138 $blocks[] = array(
1139 'from' => ($header['from'] ?? null),
1140 'date_raw' => ($header['date_raw'] ?? null),
1141 'subject' => ($header['subject'] ?? null),
1142 'message_id' => ($header['message_id'] ?? null),
1143 'in_reply_to' => ($header['in_reply_to'] ?? null),
1144 'references' => (array) ($header['references'] ?? array()),
1145 'text' => (dol_strlen($text) > 1200 ? dol_substr($text, 0, 1200) : $text),
1146 );
1147 };
1148
1149 foreach ($lines as $line) {
1150 $trim = trim((string) $line);
1151 $isSeparator = (bool) preg_match('/^(?:-{2,}\s*)?(?:original message|mensaje original|mensagem original|forwarded message|message transfere|weitergeleitete nachricht)(?:\s*-{2,})?$/iu', trim($trim, '- '));
1152 $isWroteLine = (bool) preg_match('/^(?:on|el|em|le|am|op)\s+.{1,300}\s+(?:wrote|escribio|escreveu|a ecrit|schrieb|schreef)\s*:?$/iu', $trim);
1153 $isHeaderStart = (bool) preg_match('/^(?:from|de|da|von|van|sent|enviado|enviada|date|fecha|data)\s*:/iu', $trim);
1154 if ($isSeparator || $isWroteLine || ($isHeaderStart && !$collecting)) {
1155 $flush($blocks, $current, $collecting);
1156 $collecting = true;
1157 if (!$isSeparator) $current[] = preg_replace('/^>+\s*/', '', (string) $line);
1158 continue;
1159 }
1160 if ($collecting) {
1161 if ($isHeaderStart && !empty($current) && preg_match('/(?:^|\n)\s*$/', implode("\n", array_slice($current, -2)))) {
1162 $flush($blocks, $current, $collecting);
1163 $collecting = true;
1164 }
1165 $current[] = preg_replace('/^>+\s*/', '', (string) $line);
1166 }
1167 }
1168 $flush($blocks, $current, $collecting);
1169
1170 return $blocks;
1171 }
1172
1179 private static function extractLeadingQuotedHeaderBasic($raw)
1180 {
1181 $lines = preg_split('/\n/', self::normalizeText((string) $raw));
1182 if (!is_array($lines)) $lines = array((string) $raw);
1183 $headerLines = array();
1184 $bodyLines = array();
1185 $inHeader = true;
1186 foreach ($lines as $line) {
1187 $trim = trim((string) $line);
1188 if ($inHeader && ($trim === '' || preg_match('/^(?:from|de|da|von|van|sent|enviado|enviada|date|fecha|data|to|para|subject|asunto|assunto|objet|betreff|onderwerp|message-id|in-reply-to|references)\s*:/iu', $trim))) {
1189 $headerLines[] = $line;
1190 if ($trim === '' && count($headerLines) > 1) $inHeader = false;
1191 continue;
1192 }
1193 $inHeader = false;
1194 $bodyLines[] = $line;
1195 }
1196 $header = implode("\n", $headerLines);
1201 $get = function (array $names) use ($header) {
1202 foreach ((array) $names as $name) {
1203 if (preg_match('/^'.preg_quote((string) $name, '/').'\s*:\s*(.+)$/miu', $header, $m)) return trim((string) $m[1]);
1204 }
1205 return '';
1206 };
1207 $messageId = self::normalizeMessageId($get(array('Message-ID')));
1208 $inReplyTo = self::normalizeMessageId($get(array('In-Reply-To')));
1209 return array(
1210 'from' => ($get(array('From', 'De', 'Da', 'Von', 'Van')) ?: null),
1211 'date_raw' => ($get(array('Sent', 'Enviado', 'Enviada', 'Date', 'Fecha', 'Data')) ?: null),
1212 'subject' => ($get(array('Subject', 'Asunto', 'Assunto', 'Objet', 'Betreff', 'Onderwerp')) ?: null),
1213 'message_id' => ($messageId !== '' ? $messageId : null),
1214 'in_reply_to' => ($inReplyTo !== '' ? $inReplyTo : null),
1215 'references' => self::normalizeMessageIdList(self::extractHeaderMessageIds($get(array('References')))),
1216 'body' => trim(implode("\n", $bodyLines)),
1217 );
1218 }
1219
1226 public static function extractReplyOnlyTextBasic($body)
1227 {
1228 $body = self::normalizeText((string) $body);
1229 if ($body === '') return '';
1230
1231 $separators = array(
1232 '-----Original Message-----',
1233 '----- Original Message -----',
1234 '-----Mensaje original-----',
1235 '----- Mensaje original -----',
1236 '-----Mensagem original-----',
1237 '----- Mensagem original -----',
1238 '----- Forwarded message -----',
1239 );
1240 foreach ($separators as $sep) {
1241 $pos = stripos($body, $sep);
1242 if ($pos !== false && $pos > 0) {
1243 return trim(dol_substr($body, 0, $pos, '8bit'));
1244 }
1245 }
1246
1247 $lines = preg_split("/\n/", $body);
1248 if (!is_array($lines)) return $body;
1249 $out = array();
1250 foreach ($lines as $line) {
1251 $trim = trim((string) $line);
1252 if ($trim === '') {
1253 if (!empty($out)) $out[] = '';
1254 continue;
1255 }
1256 if (preg_match('/^(from|de|sent|enviado|to|para|subject|asunto)\b[^:]{0,12}:/i', $trim)) break;
1257 if (preg_match('/^>+/', $trim)) break;
1258 $out[] = $line;
1259 if (dol_strlen(implode("\n", $out)) > 4000) break;
1260 }
1261
1262 return trim(implode("\n", $out));
1263 }
1264
1272 public static function extractQuotedThreadSnippetBasic($body, $maxLen = 1200)
1273 {
1274 $body = self::normalizeText((string) $body);
1275 $maxLen = (int) $maxLen;
1276 if ($body === '') return '';
1277 if ($maxLen <= 0) $maxLen = 1200;
1278
1279 $startPos = false;
1280 foreach (array('original message', 'mensaje original', 'mensagem original', 'forwarded message') as $needle) {
1281 $p = stripos($body, $needle);
1282 if ($p !== false && ($startPos === false || $p < $startPos)) $startPos = $p;
1283 }
1284 if ($startPos === false) {
1285 $lines = preg_split("/\n/", $body);
1286 if (!is_array($lines)) return '';
1287 $quoteLines = array();
1288 foreach ($lines as $ln) {
1289 $t = trim((string) $ln);
1290 if ($t === '') continue;
1291 if (preg_match('/^>+/', $t)) {
1292 $quoteLines[] = preg_replace('/^>+\s*/', '', $t);
1293 }
1294 }
1295 $txt = trim(implode("\n", $quoteLines));
1296 return (dol_strlen($txt) > $maxLen ? dol_substr($txt, 0, $maxLen) : $txt);
1297 }
1298
1299 $txt = trim(dol_substr($body, (int) $startPos, null, '8bit'));
1300 if (dol_strlen($txt) > $maxLen) $txt = dol_substr($txt, 0, $maxLen);
1301 return $txt;
1302 }
1303
1310 private static function fallbackCleanBody($rawBody)
1311 {
1312 $text = self::normalizeText($rawBody);
1313 if ($text === '') return '';
1314
1315 $lines = preg_split('/\n+/', $text);
1316 if (!is_array($lines)) return trim($text);
1317
1318 $out = array();
1319 foreach ($lines as $line) {
1320 $line = trim((string) $line);
1321 if ($line === '') continue;
1322
1323 $low = dol_string_nospecial($line);
1324 $low = dol_strtolower($low);
1325 if (preg_match('/^(from|de|sent|enviado|to|para|subject|asunto)\b[^:]{0,12}:/i', $line)) break;
1326 if (strpos($low, 'original message') !== false) break;
1327 if (strpos($low, 'mensaje original') !== false) break;
1328 if (strpos($low, 'forwarded message') !== false) break;
1329 if (preg_match('/^>+/', $line)) continue;
1330 if (preg_match('/\b(confidential|privacidad|confidencialidad)\b/i', $line)) continue;
1331
1332 $out[] = $line;
1333 if (dol_strlen(implode("\n", $out)) > 6000) break;
1334 }
1335
1336 $clean = trim(implode("\n", $out));
1337 if ($clean === '') {
1338 $clean = trim(dol_substr($text, 0, 2000));
1339 }
1340
1341 return $clean;
1342 }
1343
1350 private static function decodeJsonFromAiString($raw)
1351 {
1352 $raw = trim((string) $raw);
1353 if ($raw === '') return null;
1354
1355 $decoded = json_decode($raw, true);
1356 if (is_array($decoded)) return $decoded;
1357
1358 $first = strpos($raw, '{');
1359 $last = strrpos($raw, '}');
1360 if ($first === false || $last === false || $last <= $first) return null;
1361
1362 $payload = dol_substr($raw, $first, $last - $first + 1, '8bit');
1363 $decoded = json_decode($payload, true);
1364 if (!is_array($decoded)) return null;
1365
1366 return $decoded;
1367 }
1368}
if(! $sortfield) if(! $sortorder) $object
Definition account.php:100
ai_log_request($db, $user, $query, array $response, $provider, float $time, float $confidence, $status, $error='', $rawReq='', $rawRes='', array $context=array(), &$logId=null)
Log AI Request with Raw Payloads.
Definition ai.lib.php:416
Class for AI feature.
Definition ai.class.php:36
Class EmailCleaner.
processEmailCollectorMessage($parameters, &$object)
Process one EmailCollector message through the isolated EmailCleaner workflow.
static buildThreadKey($messageId, $inReplyTo, $references)
Build a stable technical thread key from RFC lineage.
insertAiRequestLogRow($entity, $actioncommId, $collectorId, $msgid, $rawBody, $cleanedText, $segments, $confidence, $engine, $model, $promptCode, $promptVersion, $contextProfileCode, $contextProfileVersion, $emailContext, $emailUnderstanding, $handoffPayload, $payload)
Insert cleaner output in the shared AI request log.
static buildNoiseSummaryFromSegments($segments)
Build minimal excluded-noise summary from segments.
static extractHeaderFieldValue($header, $fieldName)
Extract unfolded RFC822 header value.
runEmailCleaner($subject, $from, $rawBody)
Run AI cleaner with conservative fallback.
static fallbackCleanBody($rawBody)
Conservative fallback cleaner.
findActionCommIdByMessageId($entity, $msgid)
Find the agenda event created for the collected email.
static extractLeadingQuotedHeaderBasic($raw)
Parse neutral metadata from the leading header of a quoted block.
static collectAttachmentRows($attachments, $savedAttachments, $savedDir='')
Extract attachment rows with stable metadata.
static parseActionParamConfig($raw)
Parse action parameter configuration (JSON).
static detectPdfDocTypeFromAttachmentName($filename)
Detect a generic document-like type from attachment metadata.
static isRuntimeAvailable()
Check if the runtime dependencies for EmailCleaner are available.
static extractQuotedMessageBlocksBasic($body, $maxBlocks=16)
Extract quoted message blocks with neutral RFC metadata.
static decodeJsonFromAiString($raw)
Decode JSON from AI output that may contain wrappers.
static isEmailCollectorHookAction($action)
Check if the EmailCollector hook action targets EmailCleaner.
static normalizeText($text)
Normalize text to UTF-8 LF-only.
static buildMessageScopesBasic($body, $subject='', $from='', $date='', $messageId='', $inReplyTo='', $references=array())
Build deterministic current/quoted scopes and a technical timeline.
static normalizeMessageId($messageId)
Normalize one RFC message identifier for comparisons.
static extractReplyOnlyTextBasic($body)
Extract reply-only part with a conservative separator strategy.
static buildFallbackEmailUnderstanding($cleanBody)
Build a minimal understanding payload when AI structured preprocessing is unavailable.
isAiRequestLogTableAvailable()
Check if the shared AI request log table is available.
static extractMessageIdFromHeader($header)
Extract Message-ID from raw RFC822 header.
static normalizeMessageIdList($messageIds)
Normalize and de-duplicate RFC message identifiers without changing order.
__construct($db)
Constructor.
static extractQuotedThreadSnippetBasic($body, $maxLen=1200)
Build compact quoted context snippet for auditing/context transfer.
static sanitizeEmailUnderstanding($raw, $fallbackCleanBody='')
Sanitize structured understanding returned by AI.
static sanitizeStringList($items, $maxItems, $maxLen)
Sanitize a list of short strings.
static extractHeaderMessageIds($raw)
Parse message-id list from References-like header.
static buildComplianceMetadata($engine, $fallbackUsed, $confidence)
Build compliance metadata for transparency and supervision.
Class to manage privacy data masking and unmasking.
if(!isModEnabled('ai')||!getDolGlobalString('AI_ASSISTANT_ENABLED')) global $conf
The main.inc.php has been included so the following variable are now defined:
dol_now($mode='gmt')
Return date for now.
dol_strtolower($string, $encoding="UTF-8")
Convert a string to lower.
dol_string_nospecial($str, $newstr='_', $badcharstoreplace='', $badcharstoremove='', $keepspaces=0)
Clean a string from all punctuation characters to use it as a ref or login.
dol_sanitizeFileName($str, $newstr='_', $unaccent=1, $includequotes=0, $allowdash=0)
Clean a string to use it as a file name.
dol_strlen($string, $stringencoding='UTF-8')
Make a strlen call.
getDolGlobalInt($key, $default=0)
Return a Dolibarr global constant int value.
dol_substr($string, $start, $length=null, $stringencoding='', $trunconbytes=0)
Make a substring.
dol_print_date($time, $format='', $tzoutput='auto', $outputlangs=null, $encodetooutput=false, $decorate=0)
Output date in a string format according to outputlangs (or langs if not defined).
getDolGlobalString($key, $default='')
Return a Dolibarr global constant string value.
isModEnabled($module)
Is Dolibarr module enabled.
dol_syslog($message, $level=LOG_INFO, $ident=0, $suffixinfilename='', $restricttologhandler='', $logcontext=null)
Write log message into outputs.
dol_mkdir($dir, $dataroot='', $newmask='')
Creation of a directory (this can create recursive subdir)
$context
@method int call_trigger(string $triggerName, ?User $user)
Definition logout.php:42