Article technique — niveau intermédiaire/avancé
Introduction
Le format .msg d’Outlook est omniprésent dans les entreprises : c’est le format natif utilisé pour exporter, archiver ou transférer des e-mails depuis Outlook ou Exchange. Pourtant, sa structure interne reste largement méconnue — même des développeurs expérimentés utilisent des bibliothèques de type extract-msg ou msgconvert sans vraiment savoir ce qu’il se passe dessous.
Cet article vous propose de descendre jusqu’au niveau binaire : comprendre comment un .msg stocke ses données, comment en extraire le corps HTML avec les images embarquées, et pourquoi une implémentation naïve rate systématiquement le texte. Tout ça en Python pur, sans dépendance externe.
Le code présenté est issu d’un projet réel et couvre quatre couches de complexité empilées :
- OLE2/CFBF — le conteneur binaire
- MAPI — le modèle de propriétés
- LZFu — la compression RTF propriétaire Microsoft
- RTF/HTML — le format d’encodage du corps du mail
1. Le conteneur OLE2 (Compound File Binary Format)
1.1 Vue d’ensemble
Un fichier .msg n’est pas un format inventé par l’équipe Outlook. C’est un fichier OLE2 (Object Linking and Embedding 2), aussi appelé CFBF (Compound File Binary Format), spécifié par Microsoft sous la référence [MS-CFB]. Ce même format est utilisé par les anciens fichiers .doc, .xls, .ppt (avant l’ère OOXML).
Concrètement, OLE2 crée un système de fichiers virtuel à l’intérieur d’un seul fichier : il y a des répertoires (appelés storages) et des fichiers (appelés streams), organisés comme un disque dur miniature avec une FAT (File Allocation Table) interne.
1.2 Structure du header (512 octets)
Tout fichier OLE2 commence par un header de 512 octets. La signature magique aux 8 premiers octets suffit pour l’identifier :
MAGIC = b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1'
def __init__(self, data: bytes):
if data[:8] != MAGIC:
raise ValueError('Not an OLE2 file')
self.sec = 1 << struct.unpack_from('<H', data, 30)[0] # taille d'un secteur
self.mini = 1 << struct.unpack_from('<H', data, 32)[0] # taille d'un mini-secteur
self.dir_start = struct.unpack_from('<I', data, 48)[0] # 1er secteur du directory
self.cutoff = struct.unpack_from('<I', data, 56)[0] # seuil mini-stream (4096)
self.minifat_st= struct.unpack_from('<I', data, 60)[0] # 1er secteur de la MiniFAT
La taille d’un secteur est typiquement 512 octets (2⁹), parfois 4096 (2¹²) pour les gros fichiers. Le header contient aussi les 109 premières entrées de DIFAT (Double-Indirect FAT), qui pointent vers les secteurs contenant la FAT principale.
1.3 La FAT et le chaînage des secteurs
Les données sont stockées en secteurs chainés : chaque secteur a un numéro, et la FAT indique le secteur suivant (ou ENDOFCHAIN = 0xFFFFFFFE si c’est le dernier). Pour lire un stream, on part du premier secteur et on suit la chaîne :
def read(self, entry: dict) -> bytes:
sz, st = entry['size'], entry['start']
if sz < self.cutoff: # petit stream → Mini-stream
out, s = b'', st
while s not in (ENDOFCHAIN, FREESECT):
off = s * self.mini
out += self.ms_data[off: off + self.mini]
s = self.minifat[s]
else: # grand stream → secteurs normaux
out, s = b'', st
while s not in (ENDOFCHAIN, FREESECT):
out += self._sector(s)
s = self.fat[s]
return out[:sz]
Les streams de moins de 4096 octets passent par le Mini-stream : un stream spécial (lui-même chaîné via la FAT normale) qui contient des mini-secteurs de 64 octets. La Mini-FAT décrit le chaînage de ces mini-secteurs. Ce double niveau d’indirection évite de gaspiller des secteurs de 512 octets pour des streams de quelques dizaines d’octets.
1.4 Le répertoire et l’arbre rouge-noir
Les entrées de répertoire sont stockées dans des secteurs chaînés à partir de dir_start. Chaque entrée fait 128 octets et contient :
- Le nom (en UTF-16-LE, longueur sur 2 octets à l’offset 64)
- Le type : 0=inconnu, 1=storage, 2=stream, 5=root
- Trois indices d’enfant (left, right, child) formant un arbre rouge-noir
Pour lister les enfants d’un storage, il faut parcourir cet arbre. L’implémentation itérative est simple :
def children(self, idx: int) -> list:
root = self.dir[idx]['child']
if root == NOSTREAM: return []
seen, stack = set(), [root]
while stack:
ci = stack.pop()
if ci == NOSTREAM or ci in seen: continue
seen.add(ci)
ce = self.dir[ci]
if ce['left'] != NOSTREAM: stack.append(ce['left'])
if ce['right'] != NOSTREAM: stack.append(ce['right'])
return list(seen)
2. MAPI : le modèle de propriétés d’un .msg
2.1 Organisation d’un fichier .msg
Une fois le conteneur OLE2 ouvert, le .msg y organise ses données selon la spécification [MS-OXMSG]. Voici l’arborescence typique :
Root
├── __properties_version1.0 ← propriétés de longueur fixe
├── __substg1.0_0037001F ← sujet (PR_SUBJECT, Unicode)
├── __substg1.0_10130102 ← corps HTML (PR_HTML, binaire)
├── __substg1.0_10090102 ← corps RTF compressé (PR_RTF_COMPRESSED)
├── __substg1.0_10000102 ← corps texte (PR_BODY, Unicode ou ANSI)
├── __recip_version1.0_#00000000 ← destinataire 1
│ ├── __substg1.0_39FE001F ← adresse SMTP
│ └── __substg1.0_3001001F ← nom affiché
├── __recip_version1.0_#00000001 ← destinataire 2
│ └── ...
└── __attach_version1.0_#00000000 ← pièce jointe 1
├── __substg1.0_37010102 ← données binaires
├── __substg1.0_3707001F ← nom long du fichier
└── __substg1.0_3712001F ← Content-ID (pour les images embarquées)
2.2 Nomenclature des streams de propriétés
Le nom d’un stream de propriété suit le schéma __substg1.0_PPPPTTTT où :
PPPPest l’ID de propriété MAPI sur 4 chiffres hexadécimauxTTTTest le type de valeur sur 4 chiffres hexadécimaux
Les types courants :
| Code | Type | Description |
|---|---|---|
001F |
PT_UNICODE | Chaîne UTF-16-LE |
001E |
PT_STRING8 | Chaîne ANSI/système |
0102 |
PT_BINARY | Données binaires |
0040 |
PT_SYSTIME | FILETIME Windows (64 bits) |
0003 |
PT_LONG | Entier 32 bits signé |
La lecture est donc triviale une fois qu’on connaît le nom :
def prop(self, kids: list, pid: str) -> bytes | None:
pfx = f'__substg1.0_{pid}'
for i in kids:
e = self.dir[i]
if e['name'].startswith(pfx) and e['type'] == 2:
return self.read(e)
return None
# Exemples d'appels :
sujet = utf16(ole.prop(kids, '0037001F')) # PR_SUBJECT
corps_html = ole.prop(kids, '10130102') # PR_HTML (binaire)
rtf_comp = ole.prop(kids, '10090102') # PR_RTF_COMPRESSED
2.3 Les propriétés de longueur fixe
Les propriétés de types simples (entiers, booléens, FILETIME) ne sont pas dans des streams individuels mais regroupées dans __properties_version1.0. Ce stream contient des enregistrements de 16 octets chacun :
Offset 0 : type (2 octets little-endian)
Offset 2 : property ID (2 octets little-endian)
Offset 4 : flags (4 octets, ignorés ici)
Offset 8 : valeur (8 octets)
Le header du stream fait 32 octets pour le message racine, 8 octets pour les storages imbriqués (recipients, attachments). C’est un détail qui coûte cher si on l’ignore :
def fixed_prop(self, kids, prop_id, prop_type, header_size=32):
for i in kids:
e = self.dir[i]
if e['name'] == '__properties_version1.0' and e['type'] == 2:
data = self.read(e)
offset = header_size
while offset + 16 <= len(data):
ptype = struct.unpack_from('<H', data, offset)[0]
pid = struct.unpack_from('<H', data, offset + 2)[0]
if pid == prop_id and ptype == prop_type:
return data[offset + 8: offset + 16]
offset += 16
2.4 La date : FILETIME Windows
La date d’envoi (PR_MESSAGE_DELIVERY_TIME, 0x0039) est un FILETIME : un entier 64 bits little-endian comptant le nombre d’intervalles de 100 nanosecondes depuis le 1er janvier 1601 UTC. La conversion :
def filetime(data: bytes) -> str | None:
ft = struct.unpack_from('<Q', data)[0]
epoch = datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc)
dt = epoch + datetime.timedelta(microseconds=ft // 10)
return dt.isoformat()
2.5 Adresses Exchange vs SMTP
Exchange stocke parfois les adresses au format X.500 : /o=ExchangeLabs/ou=Exchange Administrative Group.../cn=Recipients/cn=.... Ces adresses sont inutilisables en dehors d’Exchange. Il faut donc toujours préférer PR_SMTP_ADDRESS (0x39FE) ou PR_EMAIL_ADDRESS (0x3003) et détecter les adresses X.500 pour les rejeter :
def is_x500(addr: str) -> bool:
return addr.startswith('/o=') or addr.startswith('x500:')
3. Le corps du mail : trois propriétés, une priorité
Un mail .msg peut avoir jusqu’à trois représentations du corps :
| Propriété MAPI | ID | Description |
|---|---|---|
PR_BODY |
0x1000 |
Texte brut (UTF-16 ou ANSI) |
PR_HTML |
0x1013 |
HTML natif (binaire, encodage variable) |
PR_RTF_COMPRESSED |
0x1009 |
RTF compressé LZFu |
La stratégie d’extraction :
- Tenter
PR_HTMLen premier — c’est le HTML le plus propre, présent quand le client a envoyé du HTML nativement. - Si absent, décompresser
PR_RTF_COMPRESSEDet en extraire le HTML (voir sections suivantes). - En dernier recours, utiliser
PR_BODYcomme texte brut.
html_raw = ole.prop(kids, '10130102')
body_html = None
if html_raw:
for enc in ('utf-8', 'latin-1', 'cp1252'):
try: body_html = html_raw.decode(enc); break
except: pass
if not body_html:
rtf_compressed = ole.prop(kids, '10090102')
if rtf_compressed:
rtf_raw = _decompress_lzfu(rtf_compressed)
body_html = _rtf_extract_html(rtf_raw)
4. LZFu : décompresser le RTF compressé
4.1 Format de l’en-tête (MS-OXRTFCP)
PR_RTF_COMPRESSED n’est pas du RTF brut : c’est un format conteneur avec un en-tête de 16 octets suivi des données (compressées ou non) :
Offset 0 : taille compressée (4 octets LE)
Offset 4 : taille décompressée (4 octets LE)
Offset 8 : magic : b'LZFu' (compressé) ou b'MELA' (non compressé)
Offset 12 : CRC32 des données décompressées (4 octets LE)
Offset 16 : données
Si le magic est MELA, les données suivent directement (non compressées). Si c’est LZFu, l’algorithme de décompression entre en jeu.
4.2 L’algorithme LZFu
LZFu est une variante de LZ77 opérant sur un ring buffer circulaire de 4096 octets. Contrairement à LZ77 standard, le ring buffer est pré-initialisé avec 207 octets de RTF fréquents — c’est le dictionnaire initial défini par la spec :
_LZFU_PREDEF = (
b'{\\rtf1\\ansi\\mac\\deff0\\deftab720{\\fonttbl;}'
b'{\\f0\\fnil \\froman \\fswiss \\fmodern \\fscript '
b'\\fdecor MS Sans SerifSymbolArialTimes New RomanCourier'
b'{\\colortbl\\red0\\green0\\blue0\r\n' # \r\n obligatoire, pas juste \n !
b'\\par \\pard\\plain\\f0\\fs20\\b\\i\\u\\tab\\tx'
)
# len(_LZFU_PREDEF) == 207 — le pointeur d'écriture démarre à 207
Piège classique : la spec impose
\r\n(2 octets) dans le dictionnaire initial. Utiliser seulement\n(1 octet) donne un dictionnaire de 206 octets au lieu de 207, ce qui décale de un tous les back-references pointant vers la fin du dictionnaire et produit du garbage à partir du premier back-reference vers cette zone.
La décompression lit les données en groupes de 9 octets : 1 octet de flags suivi de 8 tokens. Chaque bit du byte de flags (du LSB vers le MSB) indique si le token correspondant est :
- Bit = 0 : un octet littéral → copier directement dans la sortie et dans le ring buffer.
- Bit = 1 : un back-reference de 2 octets → copier N+2 octets depuis la position P du ring buffer.
def _decompress_lzfu(data: bytes) -> bytes:
uncomp_size = struct.unpack_from('<I', data, 4)[0]
buf = bytearray(4096)
buf[:len(_LZFU_PREDEF)] = _LZFU_PREDEF
wp = len(_LZFU_PREDEF) # write pointer, démarre à 207
out = bytearray()
i = 16 # skip header
while len(out) < uncomp_size and i < len(data):
flags = data[i]; i += 1
for bit in range(8):
if len(out) >= uncomp_size or i >= len(data): break
if flags & (1 << bit): # back-reference
b1, b2 = data[i], data[i+1]; i += 2
offset = ((b1 << 4) | (b2 >> 4)) & 0xFFF
length = (b2 & 0x0F) + 2 # toujours au moins 2 octets copiés
for _ in range(length):
ch = buf[offset]
out.append(ch); buf[wp] = ch
wp = (wp + 1) & 0xFFF # ring circulaire sur 12 bits
offset = (offset + 1) & 0xFFF
else: # octet littéral
ch = data[i]; i += 1
out.append(ch); buf[wp] = ch
wp = (wp + 1) & 0xFFF
return bytes(out)
Le décodage du token de back-reference mérite attention : les 2 octets b1 b2 encodent l’offset sur 12 bits (bits 15..4 des 2 octets) et la longueur sur 4 bits (bits 3..0 du second octet, +2 pour la longueur minimale).
b1 = 0xAB, b2 = 0xCD
offset = (0xAB << 4) | (0xCD >> 4) = 0xABC = 2748
length = (0xCD & 0x0F) + 2 = 0xD + 2 = 15
→ copier 15 octets depuis la position 2748 du ring buffer
5. RTF et \fromhtml1 : extraire le HTML
5.1 Le format RTF en une minute
RTF (Rich Text Format) est un format texte hiérarchique. Sa grammaire de base :
{/}: ouvre/ferme un groupe\mot: mot de contrôle (ex.\bpour gras,\f0pour police 0)\mot123: mot de contrôle avec paramètre numérique\'xx: caractère encodé en hexadécimal (ex.\'e9pouréen cp1252){\*\destination ...}: groupe optionnel — les lecteurs ne comprenant pasdestinationpeuvent l’ignorer- Tout autre caractère : texte littéral
5.2 Le format \fromhtml1 (MS-OXRTFEX)
Quand Outlook ou Word compose un mail HTML, il génère un RTF spécial contenant l’HTML entier. Ce RTF commence par \fromhtml1 et intègre le HTML via deux mécanismes complémentaires :
Les groupes \htmltag contiennent les balises HTML structurelles :
{\*\htmltag42 <div class="content">}
{\*\htmltag43 <span style="font-weight:bold">}
{\*\htmltag44 </span>}
{\*\htmltag45 </div>}
Le corps RTF entre ces groupes contient le texte visible, protégé par un toggle :
{\*\htmltag10 <p>}
\htmlrtf {\cs1\f4\fs20 ← mode RTF-only : formattage RTF équivalent, à IGNORER
\htmlrtf0 Bonjour, voici le texte. ← mode HTML : texte à COLLECTER
{\*\htmltag11 </p>}
C’est là que réside le piège principal.
5.3 Le toggle \htmlrtf / \htmlrtf0
Le mot de contrôle \htmlrtf sert d’interrupteur entre deux modes :
| Contrôle | Signification | Action |
|---|---|---|
\htmlrtf ou \htmlrtf1 |
Mode RTF-only | Ignorer le texte qui suit |
\htmlrtf0 |
Mode HTML | Collecter le texte qui suit |
Le texte entre \htmlrtf et \htmlrtf0 est la représentation RTF du contenu — il peut contenir des groupes de formatage ({\b texte}, {\i texte}) avec le même texte mais en syntaxe RTF. Un parser qui ne gère pas ce toggle va soit ignorer tout le texte (en ne collectant que les groupes \htmltag), soit collecter du texte en double.
Voici un extrait réel d’un mail Word :
{\*\htmltag84 <b>}
\htmlrtf {\b \htmlrtf0 Objet
{\*\htmltag84 }
\htmlrtf \xA0\htmlrtf0 :
{\*\htmltag92 </b>}
\htmlrtf }\htmlrtf0 RE: [MDLZ] - Configuration/paramétrage eTSMILE
Résultat attendu après extraction : <b>Objet :</b> RE: [MDLZ] - Configuration/paramétrage eTSMILE
5.4 L’algorithme d’extraction complet
L’extracteur maintient quatre états :
depth = 0 # profondeur { }
collect_mode = False # True : dans un groupe htmltag (on collecte HTML brut)
collect_depth = 0 # profondeur à laquelle collect_mode a démarré
body_started = False # True après le 1er groupe htmltag vu
html_text_mode = False # True : mode HTML (après \htmlrtf0), texte à collecter
La boucle principale a quatre branches :
while i < n:
c = s[i]
if c == '{':
depth += 1
# Détecter {\*\htmltag<N> ...} ou {\*\ibmprgS<N> ...}
if not collect_mode and is_html_group(s, i):
collect_mode = True
collect_depth = depth
body_started = True
i = skip_past_group_header(s, i)
else:
i += 1
elif c == '}':
if collect_mode and depth == collect_depth:
collect_mode = False # on quitte le groupe htmltag
depth -= 1; i += 1
elif collect_mode:
# Intérieur d'un groupe htmltag : collecter tel quel
# (en décodant \'xx et en sautant les mots de contrôle RTF)
ch, i = collect_rtf_char(s, i)
if ch: parts.append(ch)
elif body_started:
# Corps RTF entre les groupes : appliquer le toggle htmlrtf
if c == '\\':
kw, param, i = read_control_word(s, i)
if kw == 'htmlrtf':
html_text_mode = (param == 0)
elif kw == "'" and html_text_mode:
parts.append(decode_hex_char(s, i))
elif html_text_mode:
parts.append(c); i += 1
else:
i += 1 # mode RTF-only : ignorer
else:
skip_char(s, i) # préambule RTF avant le 1er htmltag
La clé : html_text_mode commence à False. Il ne passe à True qu’après le premier \htmlrtf0. Tout le texte qui apparaît avant dans le corps RTF (variables de formatage, tables de polices, etc.) est automatiquement ignoré.
6. Les images embarquées (CID)
6.1 Le Content-ID
Quand un mail HTML contient des images embarquées (logo de signature, photo, etc.), elles sont référencées dans le HTML via un schéma cid: :
<img src="cid:image001.png@01D9A3B2.7F8C4E10">
Le CID (Content-ID) correspond à la propriété MAPI PR_ATTACH_CONTENT_ID (0x3712). La pièce jointe associée est simplement un blob binaire dans __attach_version1.0_#N/__substg1.0_37010102.
6.2 Résolution côté serveur
Pour afficher ces images dans un navigateur, il faut remplacer les cid: par des URLs pointant vers un endpoint de téléchargement. En PHP :
// Construire deux maps : par content_id exact et par nom de fichier (fallback)
$cidMap = []; // cid normalisé → URL
$cidByName = []; // nom_fichier normalisé → URL
foreach ($atts as $a) {
$url = '/api/download?id=' . $a['id'];
$cid = strtolower(trim((string)($a['content_id'] ?? ''), '<> '));
if ($cid !== '') $cidMap[$cid] = $url;
$cidByName[strtolower(basename($a['nom_fichier']))] = $url;
}
// Remplacer dans le HTML
$html = preg_replace_callback('/\bcid:([^\s"\'>\)]+)/i',
function($m) use ($cidMap, $cidByName) {
$key = strtolower(trim($m[1], '<> '));
if (isset($cidMap[$key])) return $cidMap[$key];
$nameKey = strtolower(explode('@', $key)[0]);
return $cidByName[$nameKey] ?? ('cid:' . $m[1]);
}, $html);
Le fallback par nom de fichier (partie avant le @ dans le CID Outlook) est nécessaire car certains clients génèrent des CIDs différents à l’encodage et à l’affichage.
6.3 Les mails .msg imbriqués
Un .msg peut contenir des pièces jointes qui sont elles-mêmes des .msg (mails transférés). Ces mails imbriqués ont PR_ATTACH_METHOD = 5 (ATTACH_EMBEDDED_MSG) et leur contenu OLE2 est dans le stream __substg1.0_3701000D. L’extracteur les parse récursivement :
if is_msg and depth < 2:
try:
embedded = parse_ole(att_data, att_dir, depth + 1)
except Exception:
embedded = None
La limite de profondeur (depth < 2) évite les boucles infinies sur des mails pathologiques.
7. Sécurité et affichage dans le navigateur
Afficher du HTML provenant d’un mail dans un navigateur est risqué : le HTML peut contenir des scripts, des appels à des ressources externes (pixels de tracking), du CSS qui casse la mise en page parente.
7.1 Content Security Policy
Utiliser un nonce aléatoire par requête pour n’autoriser que son propre script d’initialisation :
$cspNonce = base64_encode(random_bytes(16));
header('Content-Security-Policy: '
. "default-src 'none'; "
. "script-src 'nonce-$cspNonce'; "
. "style-src 'unsafe-inline'; "
. "img-src 'self' data: blob: *; "
. "font-src *;");
7.2 Suppression des scripts dans le HTML
La CSP bloque les scripts, mais il vaut mieux les retirer aussi au niveau du contenu :
$html = preg_replace('/<script\b[^>]*>.*?<\/script>/is', '', $html);
7.3 Extraction du <body> uniquement
Le HTML d’un mail Outlook contient souvent une balise <html> et des <style> dans le <head>. Pour éviter les conflits avec la page hôte, n’injecter que le contenu du <body> :
if (preg_match('/<body[^>]*>(.*?)<\/body>/is', $html, $m)) {
echo $m[1];
}
8. Retour d’expérience : les pièges rencontrés
Cette implémentation a émergé de plusieurs sessions de débogage. Voici les problèmes non documentés (ou mal documentés) rencontrés.
Le \r\n dans le dictionnaire LZFu. La spec [MS-OXRTFCP] §2.1.4.1 définit un dictionnaire initial de 207 octets se terminant par \r\n. Utiliser \n seul donne 206 octets. Le pointeur d’écriture initial est donc décalé de 1, et chaque back-reference vers la zone >200 du ring buffer pointe sur la mauvaise case. Le résultat est du garbage en fin de décompression, avec une cascade d’erreurs difficile à diagnostiquer.
PR_HTML absent ≠ pas de HTML. Beaucoup de clients (Exchange, certaines versions d’Outlook, IBM Notes) n’écrivent pas PR_HTML. Le HTML est exclusivement dans PR_RTF_COMPRESSED. Un parser qui s’arrête à l’absence de PR_HTML retournera du texte brut pour des mails qui sont pourtant en HTML.
Le toggle \htmlrtf ignoré = texte entièrement absent. C’est le bug le plus subtil. Les groupes \htmltag contiennent les balises HTML mais pas le texte. Un extracteur qui ne collecte que ces groupes génère du HTML structurellement correct mais avec des <span></span> et <a></a> vides — le rendu est une page blanche avec uniquement des images.
Les adresses X.500. Exchange stocke parfois les adresses mail au format interne X.500 (/o=...). Il faut chercher PR_SMTP_ADDRESS (0x39FE) plutôt que PR_EMAIL_ADDRESS (0x3003), et tomber en fallback sur les storages recipients pour récupérer les vraies adresses SMTP.
Les mails IBM Notes. Notes génère des .msg avec des groupes \ibmprgS au lieu de \htmltag. La structure est identique (même toggle \htmlrtf/\htmlrtf0 pour le texte corps), mais la détection de format doit chercher les deux marqueurs.
Le header_size des __properties_version1.0. Ce stream a un header de 32 octets au niveau racine du message, mais de seulement 8 octets dans les storages recipients et attachments. Ignorer cette différence fait lire des propriétés fixes avec un décalage de 24 octets, ce qui retourne des valeurs aléatoires.
9. Format de sortie et intégration
Le parser produit un dictionnaire JSON normalisé, conçu pour une insertion directe en base de données :
{
"sha256": "a3f4...",
"sujet": "RE: Demande de devis",
"expediteur": "Alice Martin <alice@example.com>",
"destinataires": "Bob Dupont <bob@example.com>",
"cc": "Charlie <charlie@example.com>",
"date_mail": "2026-05-20T08:16:00+00:00",
"corps_texte": "Bonjour Bob,\n\nSuite à notre échange...",
"corps_html": "<html>...",
"attachments": [
{
"nom_fichier": "devis_2026.pdf",
"taille_octets": 142336,
"content_type": "application/pdf",
"content_id": null,
"chemin": "/uploads/mails/2026-05/20260520_abc123/devis_2026.pdf",
"est_msg": false,
"embedded": null
},
{
"nom_fichier": "logo.png",
"taille_octets": 4821,
"content_type": "image/png",
"content_id": "image001.png@01D9A3B2",
"chemin": "...",
"est_msg": false,
"embedded": null
}
]
}
La propriété content_id non nulle identifie les images embarquées (inline) vs les pièces jointes normales.
Le SHA-256 permet la déduplication : si le même mail est importé deux fois (pour deux clients différents par exemple), on peut détecter le doublon avant l’insertion.
Conclusion
Parser un fichier .msg sans dépendance externe est tout à fait faisable en Python pur. Le code final tient en ~550 lignes et couvre :
- La navigation OLE2 avec FAT, Mini-FAT et arbre rouge-noir
- Le modèle MAPI avec ses deux types de propriétés (streams et fixed)
- La décompression LZFu du RTF
- L’extraction HTML via les groupes
\htmltaget le toggle\htmlrtf - Les pièces jointes, Content-IDs et mails imbriqués
L’exercice est surtout une leçon sur la documentation Microsoft : les specs [MS-CFB], [MS-OXMSG], [MS-OXRTFCP] et [MS-OXRTFEX] sont publiques et précises, mais elles supposent que vous les lirez dans l’ordre et en entier. Les bugs rencontrés ici — le \r\n du dictionnaire LZFu, le toggle \htmlrtf, les headers de taille variable — sont tous documentés, mais dans des notes de bas de page ou des paragraphes que l’on saute facilement.
Références
[MS-CFB]— Compound File Binary File Format[MS-OXMSG]— Outlook Item (.msg) File Format[MS-OXRTFCP]— RTF Compression Algorithm[MS-OXRTFEX]— Rich Text Format (RTF) Extensions[MS-OXPROPS]— Exchange Server Protocols: Master Property List
Commentaires
Aucun commentaire pour l'instant. Soyez le premier !
Laisser un commentaire