feat: raccorder le pivot EML à l'interface

This commit is contained in:
grayTerminal-sh 2026-07-28 07:39:53 +02:00
parent 015841a20c
commit a3c4b4c51a
12 changed files with 723 additions and 23 deletions

View file

@ -0,0 +1,47 @@
# Test manuel du parcours EML
Ce test emploie uniquement la fixture synthétique
`tests/fixtures/eml/manual_smoke_test.eml`. Il ne nécessite aucune donnée
réelle.
## Parcours synthétique
1. Compiler avec `make -j8`.
2. Créer une enquête synthétique neuve dans un répertoire de test.
3. Importer `tests/fixtures/eml/manual_smoke_test.eml` comme preuve.
4. Sélectionner cette preuve dans la barre latérale.
5. Vérifier que laction compacte « Analyser le-mail » devient active.
6. Lancer laction et observer « Analyse complète de le-mail » dans le
panneau dactivité.
7. Pendant un premier essai, annuler la tâche depuis ce panneau et vérifier
quaucun dialogue de succès ni aucune nouvelle entité napparaît.
8. Relancer lanalyse et vérifier dans le dialogue les en-têtes, les deux
pièces jointes, les textes, les métadonnées et les avertissements relatifs
aux outils optionnels éventuellement absents.
9. Vérifier que lIBAN commençant par `FR00` est indiqué comme donnée de
démonstration invalide et nest pas proposé à lintégration.
10. Vérifier que les valeurs sont sélectionnables et copiables.
11. Fermer avec « Rejeter et fermer » et confirmer labsence de nouvelle
entité, relation ou rattachement.
12. Relancer, sélectionner quelques adresses ou domaines synthétiques, puis
choisir « Intégrer les éléments sélectionnés ».
13. Vérifier le message de bilan, le rafraîchissement du graphe, de la barre
latérale et des détails de preuve.
14. Fermer puis rouvrir lenquête synthétique et vérifier que les objets
confirmés sont toujours présents.
15. Recalculer lintégrité de la preuve et vérifier que son SHA-256 est
inchangé.
Noter séparément le comportement lorsque ExifTool, Tesseract ou les outils
Poppler ne sont pas installés : les en-têtes et lextraction MIME doivent
rester consultables.
## Test sur une copie denquête réelle
Ne jamais commencer sur lunique exemplaire dune enquête. Copier le dossier
complet, vérifier que la copie souvre, conserver une sauvegarde distincte,
puis lancer Labfy Investigation uniquement sur cette copie.
Ne jamais transmettre à Codex une preuve, une base SQLite, une pièce jointe
ou une donnée sensible. En cas danomalie, relever uniquement les étapes, les
messages techniques expurgés et le comportement observé.

View file

@ -54,6 +54,16 @@ BackgroundTask *eml_pipeline_task_new_with_tools_and_limit(
guint document_analysis_limit guint document_analysis_limit
); );
BackgroundTask *eml_pipeline_task_start(
const char *eml_path,
const char *staging_directory,
const char *evidence_id,
const DocumentAnalysisTools *tools,
BackgroundTaskCompletionCallback completion_callback,
gpointer completion_data,
GDestroyNotify completion_data_destroy
);
G_END_DECLS G_END_DECLS
#endif /* LABFY_INVESTIGATION_EML_PIPELINE_TASK_H */ #endif /* LABFY_INVESTIGATION_EML_PIPELINE_TASK_H */

View file

@ -6,6 +6,7 @@
#define LABFY_INVESTIGATION_EML_ANALYSIS_DIALOG_H #define LABFY_INVESTIGATION_EML_ANALYSIS_DIALOG_H
#include "core/eml_processing.h" #include "core/eml_processing.h"
#include "core/eml_integration.h" #include "core/eml_integration.h"
#include "core/eml_pipeline_task.h"
#include <gtk/gtk.h> #include <gtk/gtk.h>
G_BEGIN_DECLS G_BEGIN_DECLS
/** @brief Callback recevant les propositions sélectionnées, ou NULL. */ /** @brief Callback recevant les propositions sélectionnées, ou NULL. */
@ -19,5 +20,14 @@ typedef void (*EmlAnalysisDialogCallback)(GPtrArray *proposals,
void eml_analysis_dialog_present(GtkWindow *parent, void eml_analysis_dialog_present(GtkWindow *parent,
const EmlProcessingResult *result, EmlAnalysisDialogCallback callback, const EmlProcessingResult *result, EmlAnalysisDialogCallback callback,
gpointer user_data); gpointer user_data);
void eml_analysis_dialog_present_pipeline(
GtkWindow *parent,
const EmlPipelineResult *result,
const char *evidence_name,
const char *relative_path,
const char *source_sha256,
EmlAnalysisDialogCallback callback,
gpointer user_data
);
G_END_DECLS G_END_DECLS
#endif #endif

View file

@ -497,6 +497,10 @@ void main_window_set_selected_evidence(
MainWindow *main_window, MainWindow *main_window,
const EvidenceRecord *evidence_record const EvidenceRecord *evidence_record
); );
void main_window_set_eml_analysis_available(
MainWindow *main_window,
gboolean available
);
/** @brief Transmet au workspace un chemin absolu validé pour l'aperçu. */ /** @brief Transmet au workspace un chemin absolu validé pour l'aperçu. */
void main_window_set_evidence_preview(MainWindow *main_window, void main_window_set_evidence_preview(MainWindow *main_window,

View file

@ -203,6 +203,10 @@ void workspace_set_selected_evidence(
Workspace *workspace, Workspace *workspace,
const EvidenceRecord *evidence_record const EvidenceRecord *evidence_record
); );
void workspace_set_eml_analysis_available(
Workspace *workspace,
gboolean available
);
/** /**
* @brief Affiche l'aperçu local d'un fichier image ou vidéo. * @brief Affiche l'aperçu local d'un fichier image ou vidéo.

View file

@ -58,6 +58,8 @@
#include "core/person_entity_service.h" #include "core/person_entity_service.h"
#include "core/eml_processing.h" #include "core/eml_processing.h"
#include "core/eml_integration.h" #include "core/eml_integration.h"
#include "core/eml_pipeline_task.h"
#include "core/file_hash.h"
#include "database/database.h" #include "database/database.h"
#include "database/transaction.h" #include "database/transaction.h"
#include "views/create_social_account_dialog.h" #include "views/create_social_account_dialog.h"
@ -249,7 +251,15 @@ typedef struct
static char *application_osint_output_to_utf8(GBytes *bytes); static char *application_osint_output_to_utf8(GBytes *bytes);
static char *application_osint_create_timestamp(void); static char *application_osint_create_timestamp(void);
/** @brief Contexte possédé pendant la révision d'une analyse EML. */ /** @brief Contexte possédé pendant la révision d'une analyse EML. */
typedef struct { Application *application; char *evidence_identifier; } typedef struct {
Application *application;
InvestigationSession *expected_session;
char *evidence_identifier;
char *evidence_name;
char *relative_path;
char *source_sha256;
char *staging_directory;
}
ApplicationEmlReviewContext; ApplicationEmlReviewContext;
static void application_start_graph_loading( static void application_start_graph_loading(
@ -3738,7 +3748,48 @@ cleanup:
/** @brief Libère le contexte de révision EML. */ /** @brief Libère le contexte de révision EML. */
static void application_eml_review_context_free(ApplicationEmlReviewContext *context) static void application_eml_review_context_free(ApplicationEmlReviewContext *context)
{ if (context == NULL) return; g_free(context->evidence_identifier); g_free(context); } {
if (context == NULL) return;
g_free(context->evidence_identifier);
g_free(context->evidence_name);
g_free(context->relative_path);
g_free(context->source_sha256);
g_free(context->staging_directory);
g_free(context);
}
/** @brief Supprime récursivement une zone de staging créée par l'application. */
static void application_remove_staging_directory(const char *path)
{
if (path == NULL) return;
GFile *directory = g_file_new_for_path(path);
GError *error = NULL;
GFileEnumerator *enumerator = g_file_enumerate_children(directory,
G_FILE_ATTRIBUTE_STANDARD_NAME "," G_FILE_ATTRIBUTE_STANDARD_TYPE,
G_FILE_QUERY_INFO_NOFOLLOW_SYMLINKS, NULL, &error);
if (enumerator != NULL)
{
GFileInfo *info = NULL;
while ((info = g_file_enumerator_next_file(
enumerator, NULL, &error)) != NULL)
{
GFile *child = g_file_get_child(directory,
g_file_info_get_name(info));
char *child_path = g_file_get_path(child);
if (g_file_info_get_file_type(info) == G_FILE_TYPE_DIRECTORY)
application_remove_staging_directory(child_path);
else
(void) g_file_delete(child, NULL, NULL);
g_free(child_path);
g_object_unref(child);
g_object_unref(info);
}
g_object_unref(enumerator);
}
g_clear_error(&error);
(void) g_file_delete(directory, NULL, NULL);
g_object_unref(directory);
}
/** @brief Intègre la sélection EML puis actualise le graphe. */ /** @brief Intègre la sélection EML puis actualise le graphe. */
static void application_on_eml_selection_completed( static void application_on_eml_selection_completed(
@ -3749,7 +3800,12 @@ static void application_on_eml_selection_completed(
const InvestigationProject *project = NULL; const InvestigationProject *project = NULL;
GError *error = NULL; guint created = 0, reused = 0; char *message = NULL; GError *error = NULL; guint created = 0, reused = 0; char *message = NULL;
if (proposals == NULL) if (proposals == NULL)
{ application_eml_review_context_free(context); return; } {
application_remove_staging_directory(context != NULL
? context->staging_directory : NULL);
application_eml_review_context_free(context);
return;
}
if (application == NULL || application->session == NULL || if (application == NULL || application->session == NULL ||
!eml_integration_apply(investigation_session_get_database(application->session), !eml_integration_apply(investigation_session_get_database(application->session),
context->evidence_identifier, proposals, &created, &reused, &error)) context->evidence_identifier, proposals, &created, &reused, &error))
@ -3764,12 +3820,66 @@ static void application_on_eml_selection_completed(
project = investigation_session_get_project(application->session); project = investigation_session_get_project(application->session);
application_start_graph_loading(application, application_start_graph_loading(application,
investigation_project_get_database_path(project)); investigation_project_get_database_path(project));
(void) application_refresh_evidence_models(application, NULL);
} }
g_free(message); g_clear_error(&error); g_ptr_array_unref(proposals); g_free(message); g_clear_error(&error); g_ptr_array_unref(proposals);
application_remove_staging_directory(context != NULL
? context->staging_directory : NULL);
application_eml_review_context_free(context); application_eml_review_context_free(context);
} }
/** @brief Prépare une copie vérifiée puis affiche l'analyse EML locale. */ /** @brief Termine le pipeline EML sur le contexte GTK principal. */
static void application_on_eml_pipeline_completed(
BackgroundTask *task,
gpointer user_data)
{
ApplicationEmlReviewContext *context = user_data;
Application *application = context != NULL ? context->application : NULL;
BackgroundTaskState state = background_task_get_state(task);
if (application == NULL || application->main_window == NULL ||
application->session == NULL ||
application->session != context->expected_session)
{
application_remove_staging_directory(context != NULL
? context->staging_directory : NULL);
application_eml_review_context_free(context);
return;
}
if (state == BACKGROUND_TASK_STATE_CANCELLED)
{
main_window_set_status(application->main_window,
"Analyse de le-mail annulée.");
application_remove_staging_directory(context->staging_directory);
application_eml_review_context_free(context);
return;
}
if (state != BACKGROUND_TASK_STATE_COMPLETED)
{
GError *error = background_task_dup_error(task);
application_present_error(application, "Analyse EML impossible",
error != NULL ? error->message : "Le pipeline EML a échoué.");
g_clear_error(&error);
application_remove_staging_directory(context->staging_directory);
application_eml_review_context_free(context);
return;
}
EmlPipelineResult *result = background_task_get_result(task);
if (result == NULL)
{
application_present_error(application, "Analyse EML impossible",
"Le pipeline na produit aucun résultat.");
application_remove_staging_directory(context->staging_directory);
application_eml_review_context_free(context);
return;
}
eml_analysis_dialog_present_pipeline(
main_window_get_window(application->main_window),
result, context->evidence_name, context->relative_path,
context->source_sha256, application_on_eml_selection_completed,
context);
}
/** @brief Vérifie la preuve puis lance le pipeline EML en arrière-plan. */
static void application_on_analyze_eml_requested( static void application_on_analyze_eml_requested(
const char *evidence_identifier, gpointer user_data) const char *evidence_identifier, gpointer user_data)
{ {
@ -3777,8 +3887,14 @@ static void application_on_analyze_eml_requested(
const InvestigationProject *project = NULL; const InvestigationProject *project = NULL;
EvidenceDao *dao = NULL; EvidenceDao *dao = NULL;
EvidenceRecord *record = NULL; EvidenceRecord *record = NULL;
EmlProcessingResult *result = NULL;
ApplicationEmlReviewContext *context = NULL; ApplicationEmlReviewContext *context = NULL;
BackgroundTask *task = NULL;
char *root = NULL;
char *candidate = NULL;
char *source_path = NULL;
char *current_sha256 = NULL;
guint64 current_size = 0;
char *staging = NULL;
GError *error = NULL; GError *error = NULL;
if (application == NULL || application->session == NULL || if (application == NULL || application->session == NULL ||
evidence_identifier == NULL) return; evidence_identifier == NULL) return;
@ -3787,22 +3903,93 @@ static void application_on_analyze_eml_requested(
if (dao != NULL) record = evidence_dao_find_by_identifier( if (dao != NULL) record = evidence_dao_find_by_identifier(
dao, evidence_identifier, &error); dao, evidence_identifier, &error);
project = investigation_session_get_project(application->session); project = investigation_session_get_project(application->session);
if (record != NULL && project != NULL) if (record == NULL || project == NULL) goto failure;
result = eml_processing_prepare( root = g_canonicalize_filename(
investigation_project_get_root_path(project), record, &error); investigation_project_get_root_path(project), NULL);
if (result == NULL) candidate = g_build_filename(root,
application_present_error(application, "Analyse EML impossible", evidence_record_get_relative_path(record), NULL);
error != NULL ? error->message : "La copie de travail n'a pas pu être analysée."); source_path = g_canonicalize_filename(candidate, NULL);
else const char *name = evidence_record_get_original_name(record);
char *lower = name != NULL ? g_ascii_strdown(name, -1) : NULL;
gboolean is_eml = lower != NULL && g_str_has_suffix(lower, ".eml");
g_free(lower);
if (!is_eml || source_path == NULL ||
!g_str_has_prefix(source_path, root) ||
(source_path[strlen(root)] != G_DIR_SEPARATOR &&
source_path[strlen(root)] != '\0') ||
!g_file_test(source_path, G_FILE_TEST_IS_REGULAR))
{ {
context = g_new0(ApplicationEmlReviewContext, 1); g_set_error_literal(&error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
context->application = application; "La preuve sélectionnée nest pas un fichier EML exploitable.");
context->evidence_identifier = g_strdup(evidence_identifier); goto failure;
eml_analysis_dialog_present(main_window_get_window(application->main_window),
result, application_on_eml_selection_completed, context);
} }
eml_processing_result_free(result); evidence_record_free(record); if (!file_hash_compute_sha256(source_path, NULL, &current_sha256,
evidence_dao_free(dao); g_clear_error(&error); &current_size, &error) ||
g_strcmp0(current_sha256, evidence_record_get_sha256(record)) != 0)
{
if (error == NULL)
g_set_error_literal(&error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
"Lempreinte SHA-256 de la preuve diverge de "
"lempreinte enregistrée. Lanalyse est bloquée.");
goto failure;
}
staging = g_dir_make_tmp("labfy-eml-staging-XXXXXX", &error);
if (staging == NULL) goto failure;
context = g_new0(ApplicationEmlReviewContext, 1);
context->application = application;
context->expected_session = application->session;
context->evidence_identifier = g_strdup(evidence_identifier);
context->evidence_name = g_strdup(name);
context->relative_path = g_strdup(
evidence_record_get_relative_path(record));
context->source_sha256 = g_strdup(current_sha256);
context->staging_directory = staging;
staging = NULL;
DocumentAnalysisTools tools = {
.exiftool = "exiftool", .tesseract = "tesseract",
.pdfinfo = "pdfinfo", .pdftotext = "pdftotext",
.pdftoppm = "pdftoppm"
};
task = eml_pipeline_task_start(source_path, context->staging_directory,
evidence_identifier, &tools, application_on_eml_pipeline_completed,
context, NULL);
if (task == NULL)
goto failure;
context = NULL;
if (!task_manager_add(application->task_manager, task, &error))
{
background_task_cancel(task);
goto cleanup;
}
main_window_set_status(application->main_window,
"Analyse de le-mail lancée en arrière-plan.");
background_task_unref(task);
task = NULL;
goto cleanup;
failure:
if (task != NULL)
background_task_cancel(task);
if (context != NULL)
application_remove_staging_directory(context->staging_directory);
else
application_remove_staging_directory(staging);
if (context != NULL)
application_eml_review_context_free(context);
if (application != NULL)
application_present_error(application, "Analyse EML impossible",
error != NULL ? error->message :
"La preuve na pas pu être analysée.");
cleanup:
background_task_unref(task);
g_free(staging);
g_free(current_sha256);
g_free(source_path);
g_free(candidate);
g_free(root);
evidence_record_free(record);
evidence_dao_free(dao);
g_clear_error(&error);
} }
/** @brief Contexte possédé pendant la révision OCR d'un RIB. */ /** @brief Contexte possédé pendant la révision OCR d'un RIB. */
@ -5495,6 +5682,7 @@ static void application_on_evidence_selected(
investigation_root = project != NULL investigation_root = project != NULL
? investigation_project_get_root_path(project) : NULL; ? investigation_project_get_root_path(project) : NULL;
relative_path = evidence_record_get_relative_path(evidence_record); relative_path = evidence_record_get_relative_path(evidence_record);
gboolean eml_analysis_available = FALSE;
if (investigation_root != NULL && relative_path != NULL) if (investigation_root != NULL && relative_path != NULL)
{ {
canonical_root = g_canonicalize_filename(investigation_root, NULL); canonical_root = g_canonicalize_filename(investigation_root, NULL);
@ -5506,11 +5694,20 @@ static void application_on_evidence_selected(
canonical_path[strlen(canonical_root)] == G_DIR_SEPARATOR && canonical_path[strlen(canonical_root)] == G_DIR_SEPARATOR &&
g_file_test(canonical_path, G_FILE_TEST_IS_REGULAR)) g_file_test(canonical_path, G_FILE_TEST_IS_REGULAR))
{ {
const char *selected_name =
evidence_record_get_original_name(evidence_record);
char *lower_name = selected_name != NULL
? g_ascii_strdown(selected_name, -1) : NULL;
eml_analysis_available = lower_name != NULL &&
g_str_has_suffix(lower_name, ".eml");
g_free(lower_name);
main_window_set_evidence_preview(application->main_window, main_window_set_evidence_preview(application->main_window,
canonical_path, canonical_path,
evidence_record_get_original_name(evidence_record)); evidence_record_get_original_name(evidence_record));
} }
} }
main_window_set_eml_analysis_available(
application->main_window, eml_analysis_available);
original_name = original_name =
evidence_record_get_original_name( evidence_record_get_original_name(

View file

@ -113,7 +113,8 @@ static gboolean eml_pipeline_task_worker(BackgroundTask *task,
g_ptr_array_add(mime_res->warnings, g_strdup("L'extraction MIME a échoué ou ne contient aucune pièce jointe.")); g_ptr_array_add(mime_res->warnings, g_strdup("L'extraction MIME a échoué ou ne contient aucune pièce jointe."));
} }
background_task_report_progress(task, 0.75, "Analyse OCR et détection bancaire..."); background_task_report_progress(task, 0.65,
"Analyse des pièces jointes avec ExifTool…");
GPtrArray *bank_proposals = g_ptr_array_new_with_free_func((GDestroyNotify) bank_proposal_free); GPtrArray *bank_proposals = g_ptr_array_new_with_free_func((GDestroyNotify) bank_proposal_free);
GPtrArray *document_analyses = g_ptr_array_new_with_free_func( GPtrArray *document_analyses = g_ptr_array_new_with_free_func(
(GDestroyNotify) document_file_analysis_free); (GDestroyNotify) document_file_analysis_free);
@ -162,6 +163,10 @@ static gboolean eml_pipeline_task_worker(BackgroundTask *task,
g_strcmp0(att->detected_mime, "application/pdf") == 0 || g_strcmp0(att->detected_mime, "application/pdf") == 0 ||
g_strcmp0(att->content_type, "application/pdf") == 0) g_strcmp0(att->content_type, "application/pdf") == 0)
{ {
background_task_report_progress(task, 0.78,
g_strcmp0(att->detected_mime, "application/pdf") == 0
? "Extraction du texte PDF ou OCR…"
: "Reconnaissance OCR de la pièce jointe…");
if (document_analyses->len >= data->document_analysis_limit) if (document_analyses->len >= data->document_analysis_limit)
{ {
skipped_document_analyses++; skipped_document_analyses++;
@ -211,7 +216,10 @@ static gboolean eml_pipeline_task_worker(BackgroundTask *task,
} }
} }
background_task_report_progress(task, 1.0, "Analyse EML terminée avec succès."); background_task_report_progress(task, 0.92,
"Détection des données bancaires terminée.");
background_task_report_progress(task, 0.98,
"Préparation du dialogue de révision…");
EmlPipelineResult *res = g_new0(EmlPipelineResult, 1); EmlPipelineResult *res = g_new0(EmlPipelineResult, 1);
res->analysis = analysis; res->analysis = analysis;
@ -232,6 +240,8 @@ static gboolean eml_pipeline_task_worker(BackgroundTask *task,
if (out_result != NULL) if (out_result != NULL)
*out_result = res; *out_result = res;
background_task_report_progress(task, 1.0,
"Analyse EML terminée avec succès.");
return TRUE; return TRUE;
} }
@ -300,3 +310,43 @@ BackgroundTask *eml_pipeline_task_new_with_tools_and_limit(
} }
return task; return task;
} }
BackgroundTask *eml_pipeline_task_start(
const char *eml_path,
const char *staging_directory,
const char *evidence_id,
const DocumentAnalysisTools *tools,
BackgroundTaskCompletionCallback completion_callback,
gpointer completion_data,
GDestroyNotify completion_data_destroy)
{
if (eml_path == NULL || staging_directory == NULL || tools == NULL)
return NULL;
EmlPipelineTaskData *data = g_new0(EmlPipelineTaskData, 1);
data->eml_path = g_strdup(eml_path);
data->processed_evidence_dir = g_strdup(staging_directory);
data->evidence_id = g_strdup(evidence_id);
data->exiftool = g_strdup(tools->exiftool);
data->tesseract = g_strdup(tools->tesseract);
data->pdfinfo = g_strdup(tools->pdfinfo);
data->pdftotext = g_strdup(tools->pdftotext);
data->pdftoppm = g_strdup(tools->pdftoppm);
data->document_analysis_limit = DOCUMENT_ANALYSIS_MAX_PIPELINE_ITEMS;
BackgroundTask *task = background_task_new(
"Analyse complète de le-mail");
GError *error = NULL;
if (task == NULL || !background_task_start(task, eml_pipeline_task_worker,
data, eml_pipeline_task_data_free,
(GDestroyNotify) eml_pipeline_result_free,
completion_callback, completion_data, completion_data_destroy,
&error))
{
if (task != NULL)
background_task_unref(task);
else
eml_pipeline_task_data_free(data);
g_clear_error(&error);
return NULL;
}
return task;
}

View file

@ -206,3 +206,233 @@ void eml_analysis_dialog_present(GtkWindow *parent,
g_free(received); g_free(emails); g_free(domains); g_free(received); g_free(emails); g_free(domains);
g_free(sender_ips); g_free(destination_ips); g_free(sender_ips); g_free(destination_ips);
} }
static void eml_analysis_dialog_append_attachment_summary(
GString *text,
const EmlPipelineResult *result)
{
for (guint index = 0; result->mime_result != NULL &&
result->mime_result->attachments != NULL &&
index < result->mime_result->attachments->len; index++)
{
EmlAttachment *attachment = g_ptr_array_index(
result->mime_result->attachments, index);
g_string_append_printf(text,
"%s → %s | MIME %s / %s | partie %s | %"
G_GSIZE_FORMAT " octets | SHA-256 %s%s\n",
attachment->declared_filename != NULL
? attachment->declared_filename : "(sans nom)",
attachment->sanitized_filename != NULL
? attachment->sanitized_filename : "(sans nom)",
attachment->content_type != NULL
? attachment->content_type : "inconnu",
attachment->detected_mime != NULL
? attachment->detected_mime : "inconnu",
attachment->part_index != NULL ? attachment->part_index : "?",
attachment->decoded_size,
attachment->sha256 != NULL ? attachment->sha256 : "indisponible",
attachment->is_truncated ? " | TRONQUÉ" : "");
}
}
static void eml_analysis_dialog_append_document_summary(
GString *text,
GString *metadata,
GString *warnings,
const EmlPipelineResult *result)
{
for (guint index = 0; result->document_analyses != NULL &&
index < result->document_analyses->len; index++)
{
DocumentFileAnalysis *document = g_ptr_array_index(
result->document_analyses, index);
if (document->ocr != NULL && document->ocr->text != NULL)
g_string_append_printf(text, "\nOCR (%s)%s\n%s\n",
document->ocr->requested_languages,
document->ocr->execution->stdout_truncated
? " — texte tronqué/partiel" : "",
document->ocr->text);
if (document->pdf != NULL)
for (guint page_index = 0;
page_index < document->pdf->pages->len; page_index++)
{
PdfPageAnalysis *page = g_ptr_array_index(
document->pdf->pages, page_index);
g_string_append_printf(text,
"\nPDF page %u — méthode %s — état %s\n%s\n",
page->page_number,
page->method == PDF_PAGE_METHOD_NATIVE
? "texte natif" : "OCR",
document_analysis_state_code(page->state),
page->text != NULL ? page->text : "(aucun texte)");
}
if (document->metadata != NULL)
{
DocumentToolExecution *execution =
document->metadata->execution;
if (execution != NULL &&
execution->state == DOCUMENT_ANALYSIS_STATE_UNAVAILABLE)
g_string_append(warnings, "ExifTool indisponible.\n");
for (guint metadata_index = 0;
metadata_index < document->metadata->metadata->len;
metadata_index++)
{
DocumentMetadataEntry *entry = g_ptr_array_index(
document->metadata->metadata, metadata_index);
g_string_append_printf(metadata,
"%s | %s:%s | %s | %s%s\n",
entry->code, entry->original_group,
entry->original_tag, entry->raw_value,
execution != NULL && execution->version != NULL
? execution->version : "version inconnue",
entry->sensitive
? " | SENSIBLE — confirmation explicite requise" : "");
}
}
}
}
void eml_analysis_dialog_present_pipeline(
GtkWindow *parent,
const EmlPipelineResult *result,
const char *evidence_name,
const char *relative_path,
const char *source_sha256,
EmlAnalysisDialogCallback callback,
gpointer user_data)
{
if (parent == NULL || result == NULL || result->analysis == NULL) return;
EmlAnalysisDialogState *state = g_new0(EmlAnalysisDialogState, 1);
state->callback = callback; state->user_data = user_data;
state->window = GTK_WINDOW(gtk_window_new());
gtk_window_set_title(state->window, "Révision de lanalyse EML");
gtk_window_set_transient_for(state->window, parent);
gtk_window_set_modal(state->window, TRUE);
gtk_window_set_default_size(state->window, 900, 700);
GtkWidget *outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10);
gtk_widget_set_margin_start(outer, 14);
gtk_widget_set_margin_end(outer, 14);
gtk_widget_set_margin_top(outer, 14);
gtk_widget_set_margin_bottom(outer, 14);
GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10);
GtkWidget *grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(grid), 6);
gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
eml_analysis_dialog_add_field(GTK_GRID(grid), 0, "Preuve", evidence_name);
eml_analysis_dialog_add_field(GTK_GRID(grid), 1, "Chemin relatif", relative_path);
eml_analysis_dialog_add_field(GTK_GRID(grid), 2, "SHA-256", source_sha256);
eml_analysis_dialog_add_field(GTK_GRID(grid), 3, "État global",
document_analysis_state_code(result->state));
eml_analysis_dialog_add_field(GTK_GRID(grid), 4, "From",
eml_analysis_get_first_header(result->analysis, "from"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 5, "Sender",
eml_analysis_get_first_header(result->analysis, "sender"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 6, "Reply-To",
eml_analysis_get_first_header(result->analysis, "reply-to"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 7, "Return-Path",
eml_analysis_get_first_header(result->analysis, "return-path"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 8, "To",
eml_analysis_get_first_header(result->analysis, "to"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 9, "Cc",
eml_analysis_get_first_header(result->analysis, "cc"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 10, "Subject",
eml_analysis_get_first_header(result->analysis, "subject"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 11, "Date brute",
eml_analysis_get_first_header(result->analysis, "date"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 12, "Message-ID",
eml_analysis_get_first_header(result->analysis, "message-id"));
char *received = eml_analysis_dialog_join(
eml_analysis_get_header_values(result->analysis, "received"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 13,
"Received (ordre original)", received);
gtk_box_append(GTK_BOX(content), grid);
GString *attachments = g_string_new(NULL);
GString *texts = g_string_new(NULL);
GString *metadata = g_string_new(NULL);
GString *warnings = g_string_new(NULL);
eml_analysis_dialog_append_attachment_summary(attachments, result);
eml_analysis_dialog_append_document_summary(
texts, metadata, warnings, result);
for (guint index = 0; result->warnings != NULL &&
index < result->warnings->len; index++)
g_string_append_printf(warnings, "%s\n",
(char *) g_ptr_array_index(result->warnings, index));
for (guint index = 0; result->bank_proposals != NULL &&
index < result->bank_proposals->len; index++)
{
BankProposal *proposal = g_ptr_array_index(
result->bank_proposals, index);
if (!proposal->is_iban_valid ||
proposal->suggested_ocr_fix != NULL)
g_string_append_printf(warnings,
"Proposition bancaire invalide ou corrigée, "
"désélectionnée : %s\n",
proposal->raw_iban != NULL ? proposal->raw_iban : "(vide)");
}
GtkWidget *details = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(details), 6);
gtk_grid_set_column_spacing(GTK_GRID(details), 12);
eml_analysis_dialog_add_field(GTK_GRID(details), 0,
"Pièces jointes", attachments->str);
eml_analysis_dialog_add_field(GTK_GRID(details), 1,
"Texte PDF et OCR", texts->str);
eml_analysis_dialog_add_field(GTK_GRID(details), 2,
"Métadonnées", metadata->str);
eml_analysis_dialog_add_field(GTK_GRID(details), 3,
"Avertissements", warnings->str);
gtk_box_append(GTK_BOX(content), details);
state->proposals_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
eml_analysis_dialog_add_proposals(state->proposals_box, "email_address",
"E-mail", eml_analysis_get_email_addresses(result->analysis));
eml_analysis_dialog_add_proposals(state->proposals_box, "domain_name",
"Domaine", eml_analysis_get_domains(result->analysis));
eml_analysis_dialog_add_proposals(state->proposals_box, "ip_address",
"IP expéditeur", eml_analysis_get_sender_ip_addresses(result->analysis));
GPtrArray *valid_ibans = g_ptr_array_new();
for (guint index = 0; result->bank_proposals != NULL &&
index < result->bank_proposals->len; index++)
{
BankProposal *proposal = g_ptr_array_index(
result->bank_proposals, index);
if (proposal->is_iban_valid && proposal->normalized_iban != NULL &&
proposal->suggested_ocr_fix == NULL)
g_ptr_array_add(valid_ibans, proposal->normalized_iban);
}
eml_analysis_dialog_add_proposals(state->proposals_box, "iban",
"IBAN valide", valid_ibans);
g_ptr_array_unref(valid_ibans);
gtk_box_append(GTK_BOX(content), gtk_label_new(
"Propositions à intégrer explicitement"));
gtk_box_append(GTK_BOX(content), state->proposals_box);
GtkWidget *scroll = gtk_scrolled_window_new();
gtk_widget_set_vexpand(scroll, TRUE);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), content);
gtk_box_append(GTK_BOX(outer), scroll);
GtkWidget *actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_widget_set_halign(actions, GTK_ALIGN_END);
GtkWidget *close = gtk_button_new_with_label("Rejeter et fermer");
GtkWidget *integrate = gtk_button_new_with_label(
"Intégrer les éléments sélectionnés");
gtk_widget_add_css_class(integrate, "suggested-action");
g_signal_connect(close, "clicked",
G_CALLBACK(eml_analysis_dialog_on_close), state);
g_signal_connect(integrate, "clicked",
G_CALLBACK(eml_analysis_dialog_on_integrate), state);
gtk_box_append(GTK_BOX(actions), close);
gtk_box_append(GTK_BOX(actions), integrate);
gtk_box_append(GTK_BOX(outer), actions);
gtk_window_set_child(state->window, outer);
g_signal_connect(state->window, "close-request",
G_CALLBACK(eml_analysis_dialog_on_window_close), state);
g_object_set_data_full(G_OBJECT(state->window), "eml-dialog-state",
state, eml_analysis_dialog_state_free);
gtk_window_present(state->window);
g_free(received);
g_string_free(attachments, TRUE);
g_string_free(texts, TRUE);
g_string_free(metadata, TRUE);
g_string_free(warnings, TRUE);
}

View file

@ -1995,6 +1995,15 @@ void main_window_set_selected_evidence(
); );
} }
void main_window_set_eml_analysis_available(
MainWindow *main_window,
gboolean available)
{
if (main_window != NULL)
workspace_set_eml_analysis_available(
main_window->workspace, available);
}
void main_window_set_evidence_preview(MainWindow *main_window, void main_window_set_evidence_preview(MainWindow *main_window,
const char *file_path, const char *display_name) const char *file_path, const char *display_name)
{ {

View file

@ -1598,11 +1598,11 @@ Workspace *workspace_new(void)
G_CALLBACK(workspace_on_edit_evidence_clicked), workspace); G_CALLBACK(workspace_on_edit_evidence_clicked), workspace);
gtk_box_append(GTK_BOX(evidence_content), workspace->edit_evidence_button); gtk_box_append(GTK_BOX(evidence_content), workspace->edit_evidence_button);
workspace->analyze_eml_button = gtk_button_new_with_label( workspace->analyze_eml_button = gtk_button_new_with_label(
"Analyser les en-têtes EML"); "Analyser le-mail");
gtk_widget_set_halign(workspace->analyze_eml_button, GTK_ALIGN_START); gtk_widget_set_halign(workspace->analyze_eml_button, GTK_ALIGN_START);
gtk_widget_set_sensitive(workspace->analyze_eml_button, FALSE); gtk_widget_set_sensitive(workspace->analyze_eml_button, FALSE);
gtk_widget_set_tooltip_text(workspace->analyze_eml_button, gtk_widget_set_tooltip_text(workspace->analyze_eml_button,
"Créer une copie vérifiée puis analyser localement ses en-têtes"); "Vérifier la preuve puis lancer lanalyse complète en arrière-plan");
g_signal_connect(workspace->analyze_eml_button, "clicked", g_signal_connect(workspace->analyze_eml_button, "clicked",
G_CALLBACK(workspace_on_analyze_eml_clicked), workspace); G_CALLBACK(workspace_on_analyze_eml_clicked), workspace);
gtk_box_append(GTK_BOX(evidence_content), workspace->analyze_eml_button); gtk_box_append(GTK_BOX(evidence_content), workspace->analyze_eml_button);
@ -2950,6 +2950,15 @@ void workspace_set_selected_evidence(
); );
} }
void workspace_set_eml_analysis_available(
Workspace *workspace,
gboolean available)
{
if (workspace != NULL && workspace->analyze_eml_button != NULL)
gtk_widget_set_sensitive(
workspace->analyze_eml_button, available);
}
void workspace_set_evidence_preview(Workspace *workspace, void workspace_set_evidence_preview(Workspace *workspace,
const char *file_path, const char *display_name) const char *file_path, const char *display_name)
{ {

View file

@ -0,0 +1,38 @@
From: Expediteur Demo <sender@example.com>
Sender: Service Demo <service@example.net>
Reply-To: Reponse Differente <reply@example.org>
Return-Path: <bounce@example.com>
To: Destinataire Demo <recipient@example.net>
Cc: Controle Demo <audit@example.org>
Subject: Démonstration synthétique du parcours EML
Date: Tue, 28 Jul 2026 10:30:00 +0200
Message-ID: <manual-smoke-test-107@example.com>
Received: from relay2.example.net (relay2.example.net [198.51.100.20]) by mx.example.org with ESMTP id DEMO2
Received: from relay1.example.com (relay1.example.com [192.0.2.10]) by relay2.example.net with ESMTP id DEMO1
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="LABFY-DEMO-107"
--LABFY-DEMO-107
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ceci est un message entièrement synthétique destiné au test manuel.
IBAN DE DÉMONSTRATION VOLONTAIREMENT INVALIDE : FR00 0000 0000 0000 0000 0000 000
BIC SYNTHÉTIQUE DE FORMAT CONTRÔLABLE : DEMOFRPP
--LABFY-DEMO-107
Content-Type: text/plain; name="demonstration.txt"
Content-Disposition: attachment; filename="demonstration.txt"
Content-Transfer-Encoding: base64
RmljaGllciBzeW50aMOpdGlxdWUgZGUgZMOpbW9uc3RyYXRpb24uIElCQU4gREVNTyBJTlZBTElERTogRlIwMCAwMDAwIDAwMDAgMDAwMCAwMDAwIDAwMDAgMDAwLiBCSUMgREVNT0ZSUFAu
--LABFY-DEMO-107
Content-Type: image/png; name="pixel_synthetique.png"
Content-Disposition: inline; filename="pixel_synthetique.png"
Content-ID: <pixel-demo@example.com>
Content-Transfer-Encoding: base64
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=
--LABFY-DEMO-107--

View file

@ -145,6 +145,35 @@ static void wait_for_task(BackgroundTask *task)
g_main_context_iteration(NULL, TRUE); g_main_context_iteration(NULL, TRUE);
} }
static void remove_tree(const char *path)
{
GFile *directory = g_file_new_for_path(path);
GFileEnumerator *enumerator = g_file_enumerate_children(directory,
G_FILE_ATTRIBUTE_STANDARD_NAME "," G_FILE_ATTRIBUTE_STANDARD_TYPE,
G_FILE_QUERY_INFO_NOFOLLOW_SYMLINKS, NULL, NULL);
if (enumerator != NULL)
{
GFileInfo *info = NULL;
while ((info = g_file_enumerator_next_file(
enumerator, NULL, NULL)) != NULL)
{
GFile *child = g_file_get_child(directory,
g_file_info_get_name(info));
char *child_path = g_file_get_path(child);
if (g_file_info_get_file_type(info) == G_FILE_TYPE_DIRECTORY)
remove_tree(child_path);
else
g_assert_true(g_file_delete(child, NULL, NULL));
g_free(child_path);
g_object_unref(child);
g_object_unref(info);
}
g_object_unref(enumerator);
}
g_assert_true(g_file_delete(directory, NULL, NULL));
g_object_unref(directory);
}
static DocumentAnalysisTools synthetic_tools(void) static DocumentAnalysisTools synthetic_tools(void)
{ {
DocumentAnalysisTools tools = { DocumentAnalysisTools tools = {
@ -157,6 +186,67 @@ static DocumentAnalysisTools synthetic_tools(void)
return tools; return tools;
} }
typedef struct
{
gboolean called;
BackgroundTaskState state;
} PipelineCompletion;
static void pipeline_completed(BackgroundTask *task, gpointer user_data)
{
PipelineCompletion *completion = user_data;
completion->called = TRUE;
completion->state = background_task_get_state(task);
}
static void test_manual_fixture_async_start(void)
{
GError *error = NULL;
char *source_before = NULL;
gsize source_length = 0;
g_assert_true(g_file_get_contents(
"tests/fixtures/eml/manual_smoke_test.eml",
&source_before, &source_length, &error));
g_assert_no_error(error);
char *staging = g_dir_make_tmp("labfy-eml-manual-XXXXXX", &error);
g_assert_no_error(error);
DocumentAnalysisTools tools = synthetic_tools();
PipelineCompletion completion = { 0 };
BackgroundTask *task = eml_pipeline_task_start(
"tests/fixtures/eml/manual_smoke_test.eml", staging,
"synthetic-evidence", &tools, pipeline_completed,
&completion, NULL);
g_assert_nonnull(task);
wait_for_task(task);
while (!completion.called)
g_main_context_iteration(NULL, TRUE);
g_assert_cmpint(completion.state, ==,
BACKGROUND_TASK_STATE_COMPLETED);
EmlPipelineResult *result = background_task_get_result(task);
g_assert_nonnull(result);
g_assert_cmpuint(result->mime_result->attachments->len, ==, 2);
g_assert_cmpuint(result->bank_proposals->len, ==, 1);
BankProposal *bank = g_ptr_array_index(result->bank_proposals, 0);
g_assert_false(bank->is_iban_valid);
g_assert_cmpstr(bank->normalized_iban, ==,
"FR0000000000000000000000000");
g_assert_cmpuint(
eml_analysis_get_header_values(result->analysis, "received")->len,
==, 2);
char *source_after = NULL;
gsize after_length = 0;
g_assert_true(g_file_get_contents(
"tests/fixtures/eml/manual_smoke_test.eml",
&source_after, &after_length, &error));
g_assert_no_error(error);
g_assert_cmpmem(source_after, after_length, source_before, source_length);
g_free(source_after);
g_free(source_before);
background_task_unref(task);
remove_tree(staging);
g_free(staging);
}
static void test_eml_pipeline_pdf_end_to_end_and_limit(void) static void test_eml_pipeline_pdf_end_to_end_and_limit(void)
{ {
GError *error = NULL; GError *error = NULL;
@ -238,5 +328,7 @@ int main(int argc, char **argv)
test_eml_pipeline_document_analysis); test_eml_pipeline_document_analysis);
g_test_add_func("/eml-pipeline-task/pdf-end-to-end-limit", g_test_add_func("/eml-pipeline-task/pdf-end-to-end-limit",
test_eml_pipeline_pdf_end_to_end_and_limit); test_eml_pipeline_pdf_end_to_end_and_limit);
g_test_add_func("/eml-pipeline-task/manual-fixture-async",
test_manual_fixture_async_start);
return g_test_run(); return g_test_run();
} }