eml2html 2.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eml2html
|
|
3
|
+
Version: 2.4.0
|
|
4
|
+
Summary: Convertisseur de fichiers EML en HTML autonome : images inline en base64, sanitization optionnelle, extraction des pièces jointes
|
|
5
|
+
Author: Romain BEAL
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: eml,html,email,converter,mime
|
|
8
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Communications :: Email
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.8
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# Convertisseur EML en HTML
|
|
29
|
+
|
|
30
|
+
[](https://www.python.org/)
|
|
31
|
+
[](https://opensource.org/licenses/MIT)
|
|
32
|
+
[](#-prérequis)
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
Outil Python pour convertir des fichiers `.eml` (emails exportés) en fichiers `.html` autonomes, avec les images inline encodées en base64 directement dans le HTML.
|
|
36
|
+
|
|
37
|
+
## ✨ Fonctionnalités
|
|
38
|
+
|
|
39
|
+
- 🔄 Conversion d'un fichier `.eml` unique ou d'un dossier entier (mode batch)
|
|
40
|
+
- 🖼️ Extraction et intégration automatique des images inline (`cid:`) en base64
|
|
41
|
+
- 🔤 Détection et correction automatique de l'encodage de caractères (UTF-8, ISO-8859-1, etc.)
|
|
42
|
+
- 🏷️ Correction de la balise `<meta charset>` pour un affichage correct dans le navigateur
|
|
43
|
+
- 📁 Traitement par lot avec gestion d'erreurs isolée (un échec n'interrompt pas le reste)
|
|
44
|
+
- 🖥️ Interface en ligne de commande (CLI) simple, avec codes de sortie exploitables (0 = succès, 1 = échec)
|
|
45
|
+
- 📝 Les emails sans partie HTML (texte brut) sont convertis en HTML valide avec échappement
|
|
46
|
+
- 🏷️ Correction du `<meta charset>` pour les formes HTML4 (`http-equiv`) et HTML5 (`charset=`)
|
|
47
|
+
- ✅ Suite de tests (`pytest`) et intégration continue (lint `ruff` + tests sur Python 3.9–3.13)
|
|
48
|
+
- 🛡️ Option `--sanitize` : retire les éléments actifs du HTML (scripts, handlers d'événements,
|
|
49
|
+
iframes, formulaires, meta refresh, URI `javascript:`) — sans aucune dépendance externe
|
|
50
|
+
- 📎 Les pièces jointes non-image sont listées en pied du HTML (nom, type, taille) ;
|
|
51
|
+
`--extract-attachments` les sauvegarde dans un dossier dédié avec liens de téléchargement
|
|
52
|
+
- 📧 Les en-têtes de l'email (De, À, Cc, Cci, Date, Objet) sont affichés en haut du HTML
|
|
53
|
+
généré, avec décodage des accents et noms encodés (MIME encoded-words)
|
|
54
|
+
|
|
55
|
+
## 📦 Prérequis
|
|
56
|
+
|
|
57
|
+
- Python 3.8 ou supérieur
|
|
58
|
+
- Aucune dépendance externe pour l'outil (uniquement la bibliothèque standard Python)
|
|
59
|
+
- `pytest` uniquement pour exécuter les tests (`pip install pytest`)
|
|
60
|
+
|
|
61
|
+
## 🚀 Installation
|
|
62
|
+
|
|
63
|
+
### Depuis PyPI (recommandé)
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install eml2html
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Les commandes `eml2html` et `eml-to-html` (alias) sont ensuite disponibles
|
|
70
|
+
depuis n'importe quel dossier :
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
eml2html chemin/vers/email.eml
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Mise à jour : `pip install --upgrade eml2html`
|
|
77
|
+
|
|
78
|
+
### Depuis GitHub (version de développement)
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install git+https://github.com/Baebibelab/eml_to_html.git
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Sans installation (script autonome)
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
git clone https://github.com/Baebibelab/eml_to_html.git
|
|
88
|
+
cd eml_to_html
|
|
89
|
+
python eml_to_html.py chemin/vers/email.eml
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Aucune dépendance externe n'est requise dans les deux cas.
|
|
93
|
+
|
|
94
|
+
## 🛠️ Utilisation
|
|
95
|
+
|
|
96
|
+
### Convertir un seul fichier
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
python eml_to_html.py chemin/vers/email.eml
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Le fichier HTML est créé automatiquement à côté, avec le même nom (`email.html`).
|
|
103
|
+
|
|
104
|
+
### Spécifier un fichier de sortie
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
python eml_to_html.py email.eml -o sortie.html
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Convertir un dossier entier (mode batch)
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
python eml_to_html.py chemin/vers/dossier/
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Spécifier un dossier de sortie
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
python eml_to_html.py chemin/vers/dossier/ -o chemin/vers/sortie/
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Parcourir les sous-dossiers (mode récursif)
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
python eml_to_html.py chemin/vers/dossier/ --recursive
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Sans `--recursive`, seuls les `.eml` directement dans le dossier sont convertis.
|
|
129
|
+
Avec, toute l'arborescence est parcourue et **recréée telle quelle** dans la sortie
|
|
130
|
+
(`archives/2023/email.eml` → `archives/2023/email.html`).
|
|
131
|
+
|
|
132
|
+
### Afficher l'aide
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
python eml_to_html.py -h
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Nettoyer le HTML de sortie (`--sanitize`)
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
python eml_to_html.py email.eml --sanitize
|
|
142
|
+
python eml_to_html.py dossier/ --sanitize
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Par défaut, le HTML de l'email est recopié tel quel : ouvrir la sortie dans un navigateur peut
|
|
146
|
+
exécuter les scripts qu'il contient. Avec `--sanitize`, les éléments actifs sont retirés avant
|
|
147
|
+
l'écriture (liste blanche de balises et d'attributs, implémentation stdlib pure) :
|
|
148
|
+
|
|
149
|
+
| Retiré | Conservé |
|
|
150
|
+
|---|---|
|
|
151
|
+
| `<script>`, `<iframe>`, `<object>`, `<embed>`, `<form>`, `<link>` | Mise en page, tableaux, styles inoffensifs |
|
|
152
|
+
| Attributs `on...` (`onclick`, `onerror`, ...) | Images inline en base64 (`data:image/...`) |
|
|
153
|
+
| URI `javascript:`, `vbscript:`, `data:text/html` | Liens `http(s):`, `mailto:` |
|
|
154
|
+
| `<meta http-equiv="refresh">`, CSS `expression()` | Balise `<meta charset>` corrigée |
|
|
155
|
+
|
|
156
|
+
Le texte visible est toujours préservé. Recommandé pour tout email de source non fiable.
|
|
157
|
+
|
|
158
|
+
### Pièces jointes
|
|
159
|
+
|
|
160
|
+
Les pièces jointes non-image (PDF, DOCX, ...) sont automatiquement listées en pied du HTML
|
|
161
|
+
généré : nom, type MIME et taille.
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
python eml_to_html.py email.eml --extract-attachments
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Avec `--extract-attachments`, elles sont en plus sauvegardées dans un dossier
|
|
168
|
+
`<nom-du-html>_pieces-jointes/` à côté du fichier HTML, et la section en pied de page
|
|
169
|
+
contient un lien de téléchargement vers chaque fichier. Les noms de fichiers sont nettoyés
|
|
170
|
+
(traversée de chemin neutralisée, doublons suffixés `-2`, `-3`...).
|
|
171
|
+
|
|
172
|
+
### Codes de sortie
|
|
173
|
+
|
|
174
|
+
- `0` : toutes les conversions ont réussi
|
|
175
|
+
- `1` : le chemin n'existe pas, ou au moins une conversion a échoué (utile en script/CI)
|
|
176
|
+
|
|
177
|
+
### Utiliser comme bibliothèque (paquet installé)
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
from eml_to_html import EmlToHtmlConverter, batch_convert
|
|
181
|
+
|
|
182
|
+
html = EmlToHtmlConverter('email.eml').save('sortie.html')
|
|
183
|
+
succeeded, failed = batch_convert('dossier/')
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## 📋 Options
|
|
187
|
+
|
|
188
|
+
| Option | Description |
|
|
189
|
+
|---|---|
|
|
190
|
+
| `path` | Chemin d'un fichier `.eml` ou d'un dossier (obligatoire) |
|
|
191
|
+
| `-o`, `--output` | Fichier ou dossier de sortie (optionnel) |
|
|
192
|
+
| `--sanitize` | Retire les éléments actifs du HTML de sortie (scripts, handlers, iframes...) |
|
|
193
|
+
| `--extract-attachments` | Sauvegarde les pièces jointes non-image dans un dossier dédié |
|
|
194
|
+
| `-r`, `--recursive` | Parcourt aussi les sous-dossiers (arborescence recréée en sortie) |
|
|
195
|
+
| `-h`, `--help` | Affiche l'aide |
|
|
196
|
+
|
|
197
|
+
## 🧩 Structure du projet
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
eml_to_html/
|
|
201
|
+
├── eml_to_html.py # Script principal
|
|
202
|
+
├── tests/ # Suite de tests pytest
|
|
203
|
+
├── .github/workflows/ # CI (ruff + pytest)
|
|
204
|
+
├── pyproject.toml # Configuration ruff / pytest
|
|
205
|
+
├── README.md # Documentation
|
|
206
|
+
├── LICENSE # Licence du projet
|
|
207
|
+
├── CHANGELOG.md # Historique des versions
|
|
208
|
+
└── .gitignore # Fichiers ignorés par Git
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## ⚙️ Fonctionnement technique
|
|
212
|
+
|
|
213
|
+
1. Le fichier `.eml` est chargé via le module `email` de la bibliothèque standard.
|
|
214
|
+
2. Les en-têtes principaux (De, À, Cc, Cci, Date, Objet) sont décodés ( MIME encoded-words)
|
|
215
|
+
et affichés dans un bloc en haut du HTML généré.
|
|
216
|
+
2. Le corps HTML (ou texte brut en fallback) est extrait et décodé selon le charset déclaré.
|
|
217
|
+
3. La balise `<meta charset>` est corrigée pour correspondre à l'encodage réel (UTF-8).
|
|
218
|
+
4. Les pièces jointes de type image sont parcourues et converties en `data:image/...;base64,...`, puis injectées dans le HTML en remplaçant les références `cid:` ou noms de fichiers.
|
|
219
|
+
5. Le résultat est un fichier `.html` autonome, lisible sans dépendance externe (pas besoin des pièces jointes séparées).
|
|
220
|
+
|
|
221
|
+
## 🐛 Limitations connues
|
|
222
|
+
|
|
223
|
+
- Seuls les formats d'image suivants sont supportés : JPEG, PNG, GIF, BMP, WEBP
|
|
224
|
+
- Les pièces jointes non-image sont listées en pied de page (et extraites avec `--extract-attachments`),
|
|
225
|
+
mais leur contenu n'est pas intégré au HTML
|
|
226
|
+
- Sans `--sanitize`, le HTML généré n'est pas nettoyé (à ouvrir avec précaution si la source
|
|
227
|
+
n'est pas fiable) ; le flag `--sanitize` neutralise les éléments actifs
|
|
228
|
+
- Sans `--recursive`, le mode batch ne traverse pas les sous-dossiers
|
|
229
|
+
|
|
230
|
+
## 🚢 Publication PyPI
|
|
231
|
+
|
|
232
|
+
La publication est automatisée : pousser un tag `v*` (ex. `v2.4.0`) déclenche les tests
|
|
233
|
+
puis la publication sur [PyPI](https://pypi.org/project/eml2html/) via GitHub Actions
|
|
234
|
+
(secret `PYPI_API_TOKEN` requis dans les réglages du dépôt).
|
|
235
|
+
|
|
236
|
+
## 📄 Licence
|
|
237
|
+
|
|
238
|
+
Ce projet est distribué sous licence [MIT](LICENSE)
|
|
239
|
+
|
|
240
|
+
## 🔒 Confidentialité et sécurité
|
|
241
|
+
|
|
242
|
+
⚠️ **Attention** : cet outil traite des fichiers email pouvant contenir des données personnelles ou confidentielles.
|
|
243
|
+
- Aucune donnée n'est envoyée en ligne : tout le traitement est **local**.
|
|
244
|
+
- Ne commitez jamais de vrais fichiers `.eml` contenant des informations sensibles dans ce dépôt (voir `.gitignore`).
|
|
245
|
+
- Le HTML généré n'est pas sanitizé : si vous l'ouvrez dans un navigateur, méfiez-vous des scripts embarqués dans des emails provenant de sources non fiables.
|
|
246
|
+
|
|
247
|
+
## 🌟 Star History
|
|
248
|
+
|
|
249
|
+
Si cet outil vous est utile, n'hésitez pas à mettre une ⭐ au dépôt !
|
|
250
|
+
|
|
251
|
+
## 📬 Contact
|
|
252
|
+
|
|
253
|
+
Pour toute question, ouvrez une [issue](../../issues) sur ce dépôt.
|
|
254
|
+
|
|
255
|
+
## 👤 Auteur
|
|
256
|
+
|
|
257
|
+
Développé par Romain BEAL
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
eml_to_html.py,sha256=WGF5v-LbRbSN2s7hfbUEElofIhp4YHNv-yf-kL2tMd4,22183
|
|
2
|
+
eml2html-2.4.0.dist-info/licenses/LICENSE,sha256=HSpkU77W2iMbvRV_T-uPQEKaXFIQhEzE1tDKiMfkUkY,1067
|
|
3
|
+
eml2html-2.4.0.dist-info/METADATA,sha256=2872CyJrfQ7Ai4aXIVyG31v2qx6Nu8uP22IKPY534DM,10140
|
|
4
|
+
eml2html-2.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
eml2html-2.4.0.dist-info/entry_points.txt,sha256=diUybW7RFsej63Ck2Mb2b7VtESZRr9om85mJvZxUbLc,77
|
|
6
|
+
eml2html-2.4.0.dist-info/top_level.txt,sha256=pwOFCSdu8NCwne-cOisUS9nfsqih_iTKD_281TO77O4,12
|
|
7
|
+
eml2html-2.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Romain BEAL
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
eml_to_html
|
eml_to_html.py
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import base64
|
|
3
|
+
import email
|
|
4
|
+
import email.header
|
|
5
|
+
import html as html_module
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from email.policy import default
|
|
10
|
+
from html.parser import HTMLParser
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import ClassVar, Optional
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HtmlSanitizer(HTMLParser):
|
|
18
|
+
"""Filtre les éléments actifs d'un HTML d'email : scripts, handlers d'événements,
|
|
19
|
+
iframes, formulaires, meta refresh et URI javascript:.
|
|
20
|
+
|
|
21
|
+
Approche liste blanche : seules les balises et attributs explicitement autorisés
|
|
22
|
+
sont conservés, tout le reste est retiré. Le contenu textuel est préservé."""
|
|
23
|
+
|
|
24
|
+
ALLOWED_TAGS: ClassVar[frozenset] = frozenset({
|
|
25
|
+
'a', 'abbr', 'acronym', 'address', 'area', 'article', 'aside', 'b', 'bdi', 'bdo',
|
|
26
|
+
'blockquote', 'body', 'br', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'dd',
|
|
27
|
+
'del', 'details', 'dfn', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'footer',
|
|
28
|
+
'font', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hr', 'html', 'i',
|
|
29
|
+
'img', 'ins', 'kbd', 'li', 'main', 'map', 'mark', 'meta', 'nav', 'noscript', 'ol',
|
|
30
|
+
'p', 'pre', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'small', 'span',
|
|
31
|
+
'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td',
|
|
32
|
+
'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'tt', 'u', 'ul', 'var', 'wbr',
|
|
33
|
+
})
|
|
34
|
+
DROP_WITH_CONTENT: ClassVar[frozenset] = frozenset({
|
|
35
|
+
'script', 'noscript', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet',
|
|
36
|
+
'template', 'form', 'input', 'button', 'select', 'textarea', 'option', 'link',
|
|
37
|
+
'base', 'title',
|
|
38
|
+
})
|
|
39
|
+
URL_ATTRIBUTES: ClassVar[frozenset] = frozenset({
|
|
40
|
+
'href', 'src', 'background', 'data-src', 'poster', 'action', 'formaction', 'cite',
|
|
41
|
+
'longdesc', 'srcset', 'dynsrc', 'lowsrc', 'xlink:href',
|
|
42
|
+
})
|
|
43
|
+
ALLOWED_ATTRIBUTES: ClassVar[frozenset] = frozenset({
|
|
44
|
+
'abbr', 'accept', 'align', 'alt', 'axis', 'border', 'cellpadding', 'cellspacing',
|
|
45
|
+
'char', 'charoff', 'charset', 'checked', 'clear', 'color', 'cols', 'colspan',
|
|
46
|
+
'compact', 'coords', 'datetime', 'dir', 'disabled', 'enctype', 'face', 'frame',
|
|
47
|
+
'headers', 'height', 'hreflang', 'hspace', 'id', 'ismap', 'label', 'lang',
|
|
48
|
+
'language', 'maxlength', 'media', 'multiple', 'name', 'noshade', 'nowrap',
|
|
49
|
+
'open', 'readonly', 'rel', 'rev', 'rows', 'rowspan', 'rules', 'scope', 'shape',
|
|
50
|
+
'size', 'sizes', 'span', 'start', 'summary', 'tabindex', 'target', 'type',
|
|
51
|
+
'valign', 'value', 'vspace', 'width', 'class', 'style', 'title', 'role',
|
|
52
|
+
})
|
|
53
|
+
DANGEROUS_STYLE: ClassVar = re.compile(
|
|
54
|
+
r'expression\s*\(|javascript\s*:|vbscript\s*:|-moz-binding|behavior\s*:',
|
|
55
|
+
re.IGNORECASE
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
_VOID_TAGS: ClassVar[frozenset] = frozenset({
|
|
59
|
+
'area', 'br', 'col', 'hr', 'img', 'meta', 'source', 'track', 'wbr',
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
def __init__(self):
|
|
63
|
+
super().__init__(convert_charrefs=True)
|
|
64
|
+
self.out = []
|
|
65
|
+
self._skip_depth = 0
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _is_dangerous_url(value: str) -> bool:
|
|
69
|
+
value = html_module.unescape(value).strip().lower()
|
|
70
|
+
value = re.sub(r'[\s\x00-\x1f]+', '', value)
|
|
71
|
+
dangerous = ('javascript:', 'vbscript:', 'livescript:', 'mocha:')
|
|
72
|
+
return value.startswith(dangerous) or 'data:text/html' in value
|
|
73
|
+
|
|
74
|
+
def _is_allowed_attribute(self, name: str, value: str) -> bool:
|
|
75
|
+
if name.startswith('on'):
|
|
76
|
+
return False
|
|
77
|
+
if name in self.URL_ATTRIBUTES:
|
|
78
|
+
return not self._is_dangerous_url(value)
|
|
79
|
+
if name == 'style':
|
|
80
|
+
return not self.DANGEROUS_STYLE.search(html_module.unescape(value))
|
|
81
|
+
return name in self.ALLOWED_ATTRIBUTES
|
|
82
|
+
|
|
83
|
+
def _clean_attributes(self, tag, attrs):
|
|
84
|
+
clean_attrs = []
|
|
85
|
+
for name, value in attrs:
|
|
86
|
+
if not name:
|
|
87
|
+
continue
|
|
88
|
+
lname = name.lower()
|
|
89
|
+
if value is None:
|
|
90
|
+
continue
|
|
91
|
+
if tag == 'meta':
|
|
92
|
+
if lname == 'charset':
|
|
93
|
+
clean_attrs.append((name, value))
|
|
94
|
+
continue
|
|
95
|
+
if lname == 'http-equiv' and value.strip().lower() == 'content-type':
|
|
96
|
+
clean_attrs.append((name, value))
|
|
97
|
+
continue
|
|
98
|
+
if lname == 'content' and value.lower().startswith('text/html'):
|
|
99
|
+
clean_attrs.append((name, value))
|
|
100
|
+
continue
|
|
101
|
+
continue
|
|
102
|
+
if self._is_allowed_attribute(lname, value):
|
|
103
|
+
clean_attrs.append((name, value))
|
|
104
|
+
return clean_attrs
|
|
105
|
+
|
|
106
|
+
def handle_starttag(self, tag, attrs):
|
|
107
|
+
if self._skip_depth:
|
|
108
|
+
if tag in self.DROP_WITH_CONTENT and tag not in self.ALLOWED_TAGS:
|
|
109
|
+
self._skip_depth += 1
|
|
110
|
+
return
|
|
111
|
+
if tag in self.DROP_WITH_CONTENT and tag not in self.ALLOWED_TAGS:
|
|
112
|
+
self._skip_depth = 1
|
|
113
|
+
return
|
|
114
|
+
if tag not in self.ALLOWED_TAGS:
|
|
115
|
+
return
|
|
116
|
+
clean = self._clean_attributes(tag, attrs)
|
|
117
|
+
self.out.append(self._build_tag(tag, clean, self_closing=False))
|
|
118
|
+
|
|
119
|
+
def _build_tag(self, tag, attrs, self_closing):
|
|
120
|
+
parts = ['<', tag]
|
|
121
|
+
for name, value in attrs:
|
|
122
|
+
if value is None:
|
|
123
|
+
parts.append(f' {name}')
|
|
124
|
+
else:
|
|
125
|
+
escaped = value.replace('"', '"')
|
|
126
|
+
parts.append(f' {name}="{escaped}"')
|
|
127
|
+
if self_closing or tag in self._VOID_TAGS:
|
|
128
|
+
parts.append(' />')
|
|
129
|
+
else:
|
|
130
|
+
parts.append('>')
|
|
131
|
+
return ''.join(parts)
|
|
132
|
+
|
|
133
|
+
def handle_startendtag(self, tag, attrs):
|
|
134
|
+
if self._skip_depth or tag in self.DROP_WITH_CONTENT or tag not in self.ALLOWED_TAGS:
|
|
135
|
+
return
|
|
136
|
+
self.out.append(self._build_tag(tag, self._clean_attributes(tag, attrs), self_closing=True))
|
|
137
|
+
|
|
138
|
+
def handle_endtag(self, tag):
|
|
139
|
+
if self._skip_depth:
|
|
140
|
+
if tag in self.DROP_WITH_CONTENT and tag not in self.ALLOWED_TAGS:
|
|
141
|
+
self._skip_depth -= 1
|
|
142
|
+
return
|
|
143
|
+
if tag in self.ALLOWED_TAGS and tag not in self._VOID_TAGS:
|
|
144
|
+
self.out.append(f'</{tag}>')
|
|
145
|
+
|
|
146
|
+
def handle_data(self, data):
|
|
147
|
+
if not self._skip_depth:
|
|
148
|
+
self.out.append(html_module.escape(data, quote=False))
|
|
149
|
+
|
|
150
|
+
def handle_entityref(self, name):
|
|
151
|
+
if not self._skip_depth:
|
|
152
|
+
self.out.append(f'&{name};')
|
|
153
|
+
|
|
154
|
+
def handle_comment(self, data):
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
def handle_decl(self, decl):
|
|
158
|
+
if not self._skip_depth and decl and decl.upper().startswith('DOCTYPE'):
|
|
159
|
+
self.out.append(f'<!{decl}>')
|
|
160
|
+
|
|
161
|
+
def result(self) -> str:
|
|
162
|
+
return ''.join(self.out)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def sanitize_html(html_content: str) -> str:
|
|
166
|
+
"""Retire les éléments actifs d'un HTML d'email (scripts, handlers, iframes...)."""
|
|
167
|
+
sanitizer = HtmlSanitizer()
|
|
168
|
+
sanitizer.feed(html_content)
|
|
169
|
+
sanitizer.close()
|
|
170
|
+
cleaned = sanitizer.result()
|
|
171
|
+
if HtmlSanitizer.DANGEROUS_STYLE.search(cleaned):
|
|
172
|
+
cleaned = HtmlSanitizer.DANGEROUS_STYLE.sub('', cleaned)
|
|
173
|
+
return cleaned
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class EmlToHtmlConverter:
|
|
177
|
+
"""Convertit un fichier EML en fichier HTML autonome (images en base64)."""
|
|
178
|
+
|
|
179
|
+
IMAGE_TYPES: ClassVar[frozenset] = frozenset(
|
|
180
|
+
{'image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/webp'}
|
|
181
|
+
)
|
|
182
|
+
META_PATTERN: ClassVar = re.compile(
|
|
183
|
+
r'<meta\s+(?:http-equiv=["\']Content-Type["\']\s+)?'
|
|
184
|
+
r'content=["\']text/html;\s*charset=[^"\']+["\']\s*/?>',
|
|
185
|
+
re.IGNORECASE
|
|
186
|
+
)
|
|
187
|
+
CHARSET_PATTERN: ClassVar = re.compile(
|
|
188
|
+
r'<meta\s+charset=["\'][^"\']+["\']\s*/?>',
|
|
189
|
+
re.IGNORECASE
|
|
190
|
+
)
|
|
191
|
+
SRC_PATTERN: ClassVar = re.compile(
|
|
192
|
+
r'(?P<prefix>(?:src|background|data-src)\s*=\s*)(?P<quote>["\'])(?P<url>.*?)(?P=quote)',
|
|
193
|
+
re.IGNORECASE
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def __init__(self, eml_path: str, sanitize: bool = False, extract_attachments: bool = False):
|
|
197
|
+
self.eml_path = Path(eml_path)
|
|
198
|
+
self.msg = None
|
|
199
|
+
self.sanitize = sanitize
|
|
200
|
+
self.extract_attachments = extract_attachments
|
|
201
|
+
self.attachment_dir = None
|
|
202
|
+
|
|
203
|
+
def load(self):
|
|
204
|
+
with open(self.eml_path, 'rb') as f:
|
|
205
|
+
self.msg = email.message_from_binary_file(f, policy=default)
|
|
206
|
+
return self
|
|
207
|
+
|
|
208
|
+
def _decode_body(self, body_part) -> str:
|
|
209
|
+
raw_bytes = body_part.get_payload(decode=True)
|
|
210
|
+
if raw_bytes is None:
|
|
211
|
+
raise ValueError("Corps du message vide ou illisible.")
|
|
212
|
+
|
|
213
|
+
declared_charset = body_part.get_content_charset()
|
|
214
|
+
candidates = [declared_charset, 'utf-8', 'iso-8859-1']
|
|
215
|
+
candidates = [c for c in candidates if c]
|
|
216
|
+
|
|
217
|
+
for charset in candidates:
|
|
218
|
+
try:
|
|
219
|
+
return raw_bytes.decode(charset)
|
|
220
|
+
except (UnicodeDecodeError, LookupError):
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
logger.warning(
|
|
224
|
+
"Impossible de décoder avec les charsets %s, fallback 'iso-8859-1' avec 'replace'.",
|
|
225
|
+
", ".join(candidates) or "inconnus"
|
|
226
|
+
)
|
|
227
|
+
return raw_bytes.decode('iso-8859-1', errors='replace')
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _wrap_plain_text(text_content: str) -> str:
|
|
231
|
+
escaped = html_module.escape(text_content)
|
|
232
|
+
body = escaped.replace('\n', '<br>\n')
|
|
233
|
+
return (
|
|
234
|
+
'<!DOCTYPE html>\n<html>\n<head>\n'
|
|
235
|
+
'<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">\n'
|
|
236
|
+
'<title>Email</title>\n</head>\n<body>\n<p>\n' + body + '\n</p>\n</body>\n</html>\n'
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def _fix_meta_charset(self, html_content: str) -> str:
|
|
240
|
+
new_meta = '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">'
|
|
241
|
+
html_content = self.META_PATTERN.sub(new_meta, html_content, count=1)
|
|
242
|
+
html_content = self.CHARSET_PATTERN.sub(new_meta, html_content, count=1)
|
|
243
|
+
if new_meta in html_content:
|
|
244
|
+
return html_content
|
|
245
|
+
return re.sub(
|
|
246
|
+
r'(<head[^>]*>)',
|
|
247
|
+
r'\1\n' + new_meta,
|
|
248
|
+
html_content,
|
|
249
|
+
count=1,
|
|
250
|
+
flags=re.IGNORECASE
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def _embed_images(self, html_content: str) -> str:
|
|
254
|
+
for part in self.msg.walk():
|
|
255
|
+
content_type = part.get_content_type()
|
|
256
|
+
if content_type not in self.IMAGE_TYPES:
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
image_data = part.get_payload(decode=True)
|
|
260
|
+
if not image_data:
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
data_uri = f'data:{content_type};base64,{base64.b64encode(image_data).decode("ascii")}'
|
|
264
|
+
content_id = part.get('Content-ID')
|
|
265
|
+
|
|
266
|
+
if content_id:
|
|
267
|
+
cid = content_id.strip('<>')
|
|
268
|
+
if f'cid:{cid}' in html_content:
|
|
269
|
+
html_content = html_content.replace(f'cid:{cid}', data_uri)
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
image_name = part.get_filename()
|
|
273
|
+
if image_name:
|
|
274
|
+
html_content = self.SRC_PATTERN.sub(
|
|
275
|
+
lambda m, uri=data_uri, name=image_name: (
|
|
276
|
+
m.group('prefix') + m.group('quote') + uri + m.group('quote')
|
|
277
|
+
if m.group('url') == name else m.group(0)
|
|
278
|
+
),
|
|
279
|
+
html_content
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
return html_content
|
|
283
|
+
|
|
284
|
+
HEADER_FIELDS = (
|
|
285
|
+
('From', 'De'),
|
|
286
|
+
('To', 'À'),
|
|
287
|
+
('Cc', 'Cc'),
|
|
288
|
+
('Bcc', 'Cci'),
|
|
289
|
+
('Date', 'Date'),
|
|
290
|
+
('Subject', 'Objet'),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
@staticmethod
|
|
294
|
+
def _decode_header(value: str) -> str:
|
|
295
|
+
decoded = email.header.decode_header(value)
|
|
296
|
+
parts = []
|
|
297
|
+
for text, charset in decoded:
|
|
298
|
+
if isinstance(text, bytes):
|
|
299
|
+
for candidate in (charset, 'utf-8', 'iso-8859-1'):
|
|
300
|
+
if not candidate:
|
|
301
|
+
continue
|
|
302
|
+
try:
|
|
303
|
+
text = text.decode(candidate)
|
|
304
|
+
break
|
|
305
|
+
except (UnicodeDecodeError, LookupError):
|
|
306
|
+
continue
|
|
307
|
+
if isinstance(text, bytes):
|
|
308
|
+
text = text.decode('iso-8859-1', errors='replace')
|
|
309
|
+
parts.append(text)
|
|
310
|
+
return ''.join(parts).strip()
|
|
311
|
+
|
|
312
|
+
def _headers_html(self) -> str:
|
|
313
|
+
rows = []
|
|
314
|
+
for field, label in self.HEADER_FIELDS:
|
|
315
|
+
raw_value = self.msg.get(field)
|
|
316
|
+
if not raw_value:
|
|
317
|
+
continue
|
|
318
|
+
value = self._decode_header(str(raw_value))
|
|
319
|
+
if not value:
|
|
320
|
+
continue
|
|
321
|
+
rows.append(
|
|
322
|
+
f'<tr><th>{label}</th><td>{html_module.escape(value)}</td></tr>'
|
|
323
|
+
)
|
|
324
|
+
if not rows:
|
|
325
|
+
return ''
|
|
326
|
+
return (
|
|
327
|
+
'<table class="eml-headers" border="1" cellpadding="4" cellspacing="0">\n'
|
|
328
|
+
f'{chr(10).join(rows)}\n'
|
|
329
|
+
'</table>\n<hr>\n'
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
@staticmethod
|
|
333
|
+
def _safe_filename(name: str, fallback: str = 'piece-jointe') -> str:
|
|
334
|
+
name = html_module.unescape(name or '')
|
|
335
|
+
name = name.replace('\\', '/')
|
|
336
|
+
name = name.split('/')[-1].strip()
|
|
337
|
+
name = re.sub(r'[\x00-\x1f\x7f"*/:<>?|]', '_', name)
|
|
338
|
+
name = name.strip('. ')
|
|
339
|
+
return name or fallback
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def _human_size(size: int) -> str:
|
|
343
|
+
if size < 1024:
|
|
344
|
+
return f'{size} o'
|
|
345
|
+
if size < 1024 * 1024:
|
|
346
|
+
return f'{size / 1024:.1f} Ko'
|
|
347
|
+
return f'{size / (1024 * 1024):.1f} Mo'
|
|
348
|
+
|
|
349
|
+
def _collect_attachments(self):
|
|
350
|
+
attachments = []
|
|
351
|
+
seen_names = {}
|
|
352
|
+
for part in self.msg.walk():
|
|
353
|
+
if part.is_multipart() or part.get_content_maintype() == 'multipart':
|
|
354
|
+
continue
|
|
355
|
+
if part is self.msg.get_body(preferencelist=('html', 'plain')):
|
|
356
|
+
continue
|
|
357
|
+
content_type = part.get_content_type()
|
|
358
|
+
if content_type in self.IMAGE_TYPES:
|
|
359
|
+
continue
|
|
360
|
+
filename = part.get_filename()
|
|
361
|
+
if not filename:
|
|
362
|
+
continue
|
|
363
|
+
payload = part.get_payload(decode=True)
|
|
364
|
+
if payload is None:
|
|
365
|
+
continue
|
|
366
|
+
safe_name = self._safe_filename(filename)
|
|
367
|
+
seen_names[safe_name] = seen_names.get(safe_name, 0) + 1
|
|
368
|
+
if seen_names[safe_name] > 1:
|
|
369
|
+
stem, dot, ext = safe_name.rpartition('.')
|
|
370
|
+
if not stem:
|
|
371
|
+
stem, ext = safe_name, ''
|
|
372
|
+
dot = ''
|
|
373
|
+
safe_name = f'{stem}-{seen_names[safe_name]}{dot}{ext}'
|
|
374
|
+
attachments.append({
|
|
375
|
+
'filename': safe_name,
|
|
376
|
+
'original_name': filename,
|
|
377
|
+
'content_type': content_type,
|
|
378
|
+
'size': len(payload),
|
|
379
|
+
'payload': payload,
|
|
380
|
+
})
|
|
381
|
+
return attachments
|
|
382
|
+
|
|
383
|
+
def _attachments_html(self, attachments) -> str:
|
|
384
|
+
if not attachments:
|
|
385
|
+
return ''
|
|
386
|
+
rows = []
|
|
387
|
+
for att in attachments:
|
|
388
|
+
name = html_module.escape(att['filename'])
|
|
389
|
+
ctype = html_module.escape(att['content_type'])
|
|
390
|
+
size = self._human_size(att['size'])
|
|
391
|
+
link = ''
|
|
392
|
+
if self.attachment_dir is not None:
|
|
393
|
+
target = f"{self.attachment_dir.name}/{att['filename']}"
|
|
394
|
+
target = target.replace('"', '%22')
|
|
395
|
+
link = f' — <a href="{target}">télécharger</a>'
|
|
396
|
+
rows.append(
|
|
397
|
+
f'<li><strong>{name}</strong> ({ctype}, {size}){link}</li>'
|
|
398
|
+
)
|
|
399
|
+
section = (
|
|
400
|
+
'<hr>\n'
|
|
401
|
+
'<h2>Pièces jointes</h2>\n'
|
|
402
|
+
f'<ul>\n{chr(10).join(rows)}\n</ul>\n'
|
|
403
|
+
)
|
|
404
|
+
return section
|
|
405
|
+
|
|
406
|
+
def _write_attachments(self, attachments, html_file: Path):
|
|
407
|
+
if not attachments:
|
|
408
|
+
return
|
|
409
|
+
attachment_dir = html_file.parent / (html_file.stem + '_pieces-jointes')
|
|
410
|
+
attachment_dir.mkdir(parents=True, exist_ok=True)
|
|
411
|
+
self.attachment_dir = attachment_dir
|
|
412
|
+
for att in attachments:
|
|
413
|
+
target = attachment_dir / att['filename']
|
|
414
|
+
with open(target, 'wb') as f:
|
|
415
|
+
f.write(att['payload'])
|
|
416
|
+
logger.info("Pièce jointe extraite : %s", target)
|
|
417
|
+
|
|
418
|
+
def convert(self) -> str:
|
|
419
|
+
if self.msg is None:
|
|
420
|
+
self.load()
|
|
421
|
+
|
|
422
|
+
body_part = self.msg.get_body(preferencelist=('html', 'plain'))
|
|
423
|
+
if body_part is None:
|
|
424
|
+
raise ValueError("Impossible de trouver un corps HTML ou texte dans cet email.")
|
|
425
|
+
|
|
426
|
+
html_content = self._decode_body(body_part)
|
|
427
|
+
|
|
428
|
+
if body_part.get_content_type() == 'text/plain':
|
|
429
|
+
html_content = self._wrap_plain_text(html_content)
|
|
430
|
+
else:
|
|
431
|
+
html_content = self._fix_meta_charset(html_content)
|
|
432
|
+
html_content = self._embed_images(html_content)
|
|
433
|
+
if self.sanitize:
|
|
434
|
+
html_content = sanitize_html(html_content)
|
|
435
|
+
|
|
436
|
+
headers_section = self._headers_html()
|
|
437
|
+
if headers_section:
|
|
438
|
+
if '<body' in html_content.lower():
|
|
439
|
+
match = re.search(r'<body[^>]*>', html_content, re.IGNORECASE)
|
|
440
|
+
idx = match.end()
|
|
441
|
+
html_content = html_content[:idx] + '\n' + headers_section + html_content[idx:]
|
|
442
|
+
else:
|
|
443
|
+
html_content = headers_section + html_content
|
|
444
|
+
|
|
445
|
+
attachments = self._collect_attachments()
|
|
446
|
+
if attachments:
|
|
447
|
+
section = self._attachments_html(attachments)
|
|
448
|
+
if '</body>' in html_content.lower():
|
|
449
|
+
idx = html_content.lower().rfind('</body>')
|
|
450
|
+
html_content = html_content[:idx] + section + html_content[idx:]
|
|
451
|
+
else:
|
|
452
|
+
html_content += '\n' + section
|
|
453
|
+
return html_content
|
|
454
|
+
|
|
455
|
+
def save(self, html_file: Optional[str] = None) -> str:
|
|
456
|
+
if self.msg is None:
|
|
457
|
+
self.load()
|
|
458
|
+
if html_file is None:
|
|
459
|
+
html_file = self.eml_path.with_suffix('.html')
|
|
460
|
+
html_file = Path(html_file)
|
|
461
|
+
|
|
462
|
+
attachments = self._collect_attachments()
|
|
463
|
+
if self.extract_attachments and attachments:
|
|
464
|
+
self._write_attachments(attachments, html_file)
|
|
465
|
+
|
|
466
|
+
html_content = self.convert()
|
|
467
|
+
self.attachment_dir = None
|
|
468
|
+
|
|
469
|
+
with open(html_file, 'w', encoding='utf-8') as f:
|
|
470
|
+
f.write(html_content)
|
|
471
|
+
|
|
472
|
+
return str(html_file)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def batch_convert(input_dir: str, output_dir: Optional[str] = None, sanitize: bool = False,
|
|
476
|
+
extract_attachments: bool = False, recursive: bool = False):
|
|
477
|
+
"""Convertit tous les fichiers .eml d'un dossier. Retourne (réussis, échecs).
|
|
478
|
+
|
|
479
|
+
En mode récursif, les sous-dossiers sont parcourus et la structure est
|
|
480
|
+
recréée dans le dossier de sortie."""
|
|
481
|
+
input_dir = Path(input_dir)
|
|
482
|
+
output_dir = Path(output_dir) if output_dir else input_dir
|
|
483
|
+
|
|
484
|
+
if recursive:
|
|
485
|
+
eml_files = sorted(p for p in input_dir.rglob('*.eml') if p.is_file())
|
|
486
|
+
else:
|
|
487
|
+
eml_files = sorted(
|
|
488
|
+
p for p in input_dir.iterdir()
|
|
489
|
+
if p.is_file() and p.suffix.lower() == '.eml'
|
|
490
|
+
)
|
|
491
|
+
if not eml_files:
|
|
492
|
+
logger.warning("Aucun fichier .eml trouvé dans %s", input_dir)
|
|
493
|
+
return 0, 0
|
|
494
|
+
|
|
495
|
+
succeeded, failed = 0, 0
|
|
496
|
+
for eml_file in eml_files:
|
|
497
|
+
try:
|
|
498
|
+
html_file = _output_path_for(eml_file, input_dir, output_dir)
|
|
499
|
+
html_file.parent.mkdir(parents=True, exist_ok=True)
|
|
500
|
+
converter = EmlToHtmlConverter(
|
|
501
|
+
eml_file, sanitize=sanitize, extract_attachments=extract_attachments
|
|
502
|
+
)
|
|
503
|
+
converter.save(html_file)
|
|
504
|
+
succeeded += 1
|
|
505
|
+
logger.info("✓ %s → %s", eml_file, html_file)
|
|
506
|
+
except (OSError, ValueError, UnicodeError) as e:
|
|
507
|
+
failed += 1
|
|
508
|
+
logger.error("✗ Échec pour %s : %s", eml_file.name, e)
|
|
509
|
+
|
|
510
|
+
return succeeded, failed
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _output_path_for(eml_file: Path, input_dir: Path, output_dir: Path) -> Path:
|
|
514
|
+
"""Chemin du HTML de sortie : à plat hors récursif, miroir de l'arborescence en récursif."""
|
|
515
|
+
if eml_file.parent == input_dir:
|
|
516
|
+
return output_dir / (eml_file.stem + '.html')
|
|
517
|
+
relative = eml_file.parent.relative_to(input_dir)
|
|
518
|
+
return output_dir / relative / (eml_file.stem + '.html')
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def main():
|
|
522
|
+
parser = argparse.ArgumentParser(description="Convertit des fichiers EML en HTML.")
|
|
523
|
+
parser.add_argument('path', help="Chemin d'un fichier .eml ou d'un dossier")
|
|
524
|
+
parser.add_argument('-o', '--output', help="Fichier ou dossier de sortie", default=None)
|
|
525
|
+
parser.add_argument(
|
|
526
|
+
'--sanitize',
|
|
527
|
+
action='store_true',
|
|
528
|
+
help="Retire les éléments actifs du HTML de sortie (scripts, handlers, "
|
|
529
|
+
"iframes, URI javascript:) — recommandé pour des emails non fiables"
|
|
530
|
+
)
|
|
531
|
+
parser.add_argument(
|
|
532
|
+
'--extract-attachments',
|
|
533
|
+
action='store_true',
|
|
534
|
+
help="Sauvegarde les pièces jointes non-image dans un dossier à côté du HTML "
|
|
535
|
+
"et les liste en pied de page avec un lien de téléchargement"
|
|
536
|
+
)
|
|
537
|
+
parser.add_argument(
|
|
538
|
+
'-r', '--recursive',
|
|
539
|
+
action='store_true',
|
|
540
|
+
help="Parcourt aussi les sous-dossiers (l'arborescence est recréée dans la sortie)"
|
|
541
|
+
)
|
|
542
|
+
args = parser.parse_args()
|
|
543
|
+
|
|
544
|
+
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
|
545
|
+
|
|
546
|
+
path = Path(args.path)
|
|
547
|
+
|
|
548
|
+
if not path.exists():
|
|
549
|
+
logger.error("Le chemin spécifié n'existe pas : %s", path)
|
|
550
|
+
return 1
|
|
551
|
+
|
|
552
|
+
if path.is_dir():
|
|
553
|
+
succeeded, failed = batch_convert(
|
|
554
|
+
path, args.output,
|
|
555
|
+
sanitize=args.sanitize,
|
|
556
|
+
extract_attachments=args.extract_attachments,
|
|
557
|
+
recursive=args.recursive,
|
|
558
|
+
)
|
|
559
|
+
if failed:
|
|
560
|
+
logger.error("%d fichier(s) en échec sur %d traité(s).", failed, succeeded + failed)
|
|
561
|
+
return 1
|
|
562
|
+
return 0
|
|
563
|
+
|
|
564
|
+
try:
|
|
565
|
+
converter = EmlToHtmlConverter(
|
|
566
|
+
path, sanitize=args.sanitize, extract_attachments=args.extract_attachments
|
|
567
|
+
)
|
|
568
|
+
html_file = converter.save(args.output)
|
|
569
|
+
logger.info("Fichier HTML enregistré : %s", html_file)
|
|
570
|
+
except (OSError, ValueError, UnicodeError) as e:
|
|
571
|
+
logger.error("Erreur lors de la conversion : %s", e)
|
|
572
|
+
return 1
|
|
573
|
+
|
|
574
|
+
return 0
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
if __name__ == "__main__":
|
|
578
|
+
sys.exit(main())
|