C’est quoi NeoMutt? C’est un MUA — pour Mail User Agent ou logiciel client de gestion des courriel — utilisable dans le terminal. Il est entièrement personnalisable et offre une interface graphique minimale pilotable entièrement au clavier.
Je ne sais plus vraiment si j’aime Neomutt parce j’aime mon terminal l’inverse. Toujours est-il que depuis quelques années Neomutt facilite la gestion de toutes mes boites courriel au quotidien. Toutes? Non une irréductible boite résiste à l’envahisseur! Car pour l’y ajouter, je dois pouvoir envoyer des courriels avec une mise en forme riche (HTML) avec des images intégrées et ma configuration ne le permettait pas.
Et franchement, vous vous voyez écrire le code HTML de vos courrier électronique dans votre éditeur de code? Moi pas! Mais les écrire en markdown et les convertir ensuite automatiquement, pourquoi pas? C’est ce que j’ai choisi de faire, et je vais vous l’expliquer dans cet article.
Mais d’abord un peu de théorie! Si vous connaissez déjà le fonctionnement et la structure des courriels multipart, allez directement à la partie pratique.
Vous avez dit multipart?
Au commencement les courriels étaient rédigés et envoyés en texte brut — sans aucune mise en forme — en utilisant les caractères ASCII uniquement (RFC 822).
Mais pour permettre l’envoi d’autres types de contenus, la RFC 2046 définit le standard MIME (pour Multipurpose Internet Mail Extentions). Il donne la possibilité d’ajouter plusieurs parties dans un même message comme par exemple une pièce jointe au format PDF:
1 | I multipart/mixed - <no description>
2 | I ├─>text/plain - <no description>
3 | A └─>application/pdf - my_document.pdf
Notre message contient deux éléments différents regroupés dans un conteneur:
- le conteneur parent
multipart/mixed: il nous indique que le message contient plusieurs types de contenu (ici message en texte brut + pièce jointe); - la première partie: le texte du message;
- la la seconde partie: la pièce jointe au format PDF.
Le I à gauche signifie que le contenu est inclus dans le message lui même (Inline), le A une pièce jointe (Attachment).
Cependant, un même contenu peut être intégré plusieurs fois dans un même message sous différentes formes:
- un même texte disponible avec mises en forme différentes grâce au type
multipart/alternativedéfinis par la RFC 2046 (section 5.1); - un même texte disponible en plusieurs langues grâce type
multipart/multilingualdéfinis par la RFC 8255.
Vous l’avez compris c’est multipart/alternative qui nous intéresse, en voici un exemple:
1 | I multipart/alternative - <no description>
2 | I ├─>text/plain - <no description>
3 | I └─>text/html - <no description>
Notre contenu multipart/alternative contient deux enfants, un au format texte brut et l’autre en HTML.
Ces deux enfants contiennent les mêmes informations, mais présentées différemment.
Le contenu relatif
Comme je le disais en introduction, je dois envoyer du texte accompagné de captures d’écran, et je veux qu’elles soient intégrées directement à la version HTML de mon message comme un seul et même document.
Pas question donc de mettre les images en pièce jointe (rappelez vous le A pour Attachement) car elles ne pourront pas être affichée en même temps que le texte.
Il existe le type de contenu multipart/related pour cet usage définit par la RFC2387.
Il nous permet d’intégrer dans un seul courriel une multitude de contenus différents en relation les un aux autres, comme dans l’exemple ci-dessous:
1 | I multipart/related - <no description>
2 | I ├─>multipart/alternative - <no description>
3 | I │ ├─>text/plain - <no description>
4 | I │ └─>text/html - <no description>
5 | I └─>image/jpeg - a simple screenshot
Tous les contenus sont de type en ligne (I), le contenu de type multipart/related est parent de tous les autres.
Ce type permet de définir l’ensemble de ses éléments enfants comme un seul et même contenu composite:
- notre
multipart/alternativelui même parent du document texte et du document HTML; - notre capture d’écran au format JPEG.
Mais alors comment faire le lien entre l’image et le document HTML? Pour répondre à cette question il faut examiner le code source du courriel.
Structure d’un courriel multipart/related
Le code source du courriel utilisé dans cette partie est téléchargeable ici
La structure d’un courriel — son code source — est relativement simple. Elle commence par un ensemble d’entêtes générales à l’ensemble du courriel permettant de définir l’expéditeur, le destinataire, le sujet et un tas d’autres métadonnées.
Return-Path: <test@example.com>
Date: Wed, 31 Dec 2025 01:10:56 +0100
From: Yorick Barbanneau <test@example.com>
To: user2@example.com
Subject: Un message de test
Message-ID: <abcdef1234567890>
MIME-Version: 1.0
Content-Type: multipart/related; boundary="jmgb3m74lk6qo2qq"
[... corps du message ...]
Ces entêtes générales, sous la forme Champs: valeur sont séparées du corps de notre courriel par une ligne vide.
L’entête Content-Type définit le type de contenu comme multipart/related.
Nous avons deux sous parties enfant: multipart/alternative et la capture d’écran au format jpeg.
La chaine de caractère donnée par boundary permet de les délimiter.
Chaque élément composant le corps d’un courriel commence aussi par un ensemble d’entêtes qui lui sont propre et éventuellement un contenu.
Corps du message: Multipart/relative
Dans le corps du message, le premier élément est le conteneur multipart/alternative:
--jmgb3m74lk6qo2qq
Content-Type: multipart/alternative; boundary="uhefazpdavz5bhlp"
Content-Disposition: inline
Il commence par le délimiteur donné par son parent précédé de deux traits d’union (--jmgb3m74lk6qo2qq).
Vient ensuite ses deux entêtes.
Notre conteneur définit à son tour le délimiteur à utiliser par ses deux enfants (texte brut + texte HTML) dans son entête Content-Type.
Vient ensuite son premier enfant, le contenu au format texte brut:
--uhefazpdavz5bhlp
Content-Type: text/plain; charset=utf-8
Content-Disposition: inline
Content-Transfer-Encoding: 8bit
[... contenu du message texte brut ...]
Et enfin son second enfant, le contenu au format HTML:
--uhefazpdavz5bhlp
Content-Type: text/html; charset=utf-8
Content-Disposition: inline
Content-Transfer-Encoding: 8bit
[... contenu au format html ...]
Ils commencent tous les deux par le délimiteur donné par l’entête Content-Type leur parent (multipart/alternative).
Corps du message: l’image JPEG
Enfin la dernière partie du courriel, l’image:
--jmgb3m74lk6qo2qq
Content-Type: image/jpeg
Content-ID: <b2ce6ec99074ffbc9b30c785bf63af69>
Content-Description: a simple screenshot
Content-Disposition: inline
Content-Transfer-Encoding: base64
[... image encodée en base64 ...]
- Nous retrouvons le séparateur de son parent (
multipart/related). - Ensuite vient le type MIME de contenu.
Content-IDpermet de définir un identifiant unique au contenu. C’est cet identifiant qui sera utilisé dans les contenus qui la référencent.Content-Descriptionpermet de donner une description au contenu, celle ci sera utile pour certains clients mail comme Mutt, NeoMutt, Aerc).
Corps du message: l’image dans le code HTML
Regardons maintenant de plus près le code HTML, plus spécifiquement la balise <img> utilisée pour afficher la capture d’écran:
<img src="cid:b2ce6ec99074ffbc9b30c785bf63af69"
alt="une image pour illustrer" />
Le champs src de la balise image contient cid: suivi du même identifiant que celui donné par l’entête Content-ID de l’image JPEG.
C’est grâce à ce mécanisme que le client courriel utilisé par le destinataire sera en mesure de faire le lien entre ces deux éléments.
En pratique
Écrire des message en utilisant Markdown est à la fois simple et rapide. Et avec l’aide de Pandoc — véritable couteau suisse de la conversion de document — sa conversion en HTML le sera tout autant.
Comment ça marche?
La transformation du message Markdown et l’intégration des éléments qui composent le courriel final se fait à l’aide d’un script Bash appelé par une macro NeoNutt.
Ce script se charge de:
- parser le fichier markdown à la recherche de lien pointant vers des fichier locaux:
- en créant un identifiant unique pour chacun des fichiers;
- en remplaçant les liens vers ces fichiers par des références
cid:<identifiant>dans le source markdown.
- convertir le source markdown en ficher en fichier HTML avec
pandoc; - créer le fichier de commande NeoMutt permettant d’intégrer le tout dans le courriel.
Viens ensuite la configuration de la macro NeoMutt pour appeler le script et importer le fichier de commandes
Pré requis
Bien entendu il faut installer Pandoc, si vous êtes utilisateur de Debian ou dérivé:
sudo apt install pandoc
Une fois installé, il faut ajouter un template personnalisé afin de simplifier au maximum le code HTML produit.
Ce template est disponible sur mon dépôt git et doit se trouver dans le répertoire ~/.local/share/pandoc/template/.
# Créer le répertoire template
mkdir -p ~/.local/share/pandoc/templates
# Télécharger le template email.html
curl "https://git.epha.se/ephase/nix/raw/branch/main/modules/home-manager/accounts/email/files/email.html" \
-o email.html
# y mettre le template email téléchargé depuis mon dépôt git
mv email.html ~/.local/share/pandoc/templates/
Le script Bash téléchargeable sur mon dépôt git, je l’ai mis dans le dossier de configuration de NeoMutt:
# Télécharger le script
curl "https://git.epha.se/ephase/nix/raw/branch/main/modules/home-manager/accounts/email/files/md-multipart-convert.sh" \
-o md-multipart-convert.sh
# Le rendre exécutable
chmod +x md-multipart-convert-sh
# Le copier au bon endroit
mv md-multipart-convert-sh ~/.config/neomutt/
Le script
Il est lancé par une macro NeoMutt qui lui passe via un pipe le contenu du message à traiter. Dans la mesure du possible, j’utilise des mécanisme interne à Bash.
Il prend en paramètre le répertoire dans lequel mettre les fichiers qu’il va créer. Je ne vais détailler ici seulement les parties les plus intéressantes.
Parser le fichier Markdown
C’est la fonction process_piped_file qui s’en charge:
process_piped_file() {
local sum file description
MARKDOWN_SOURCE=$(</dev/stdin)
# Get only markdown link to files
# As bash does not have a lookahead / lookbehind operator, the trick here is
# search for link that seems to point to filesystem beginning with:
# - ./
# - /
# - ~
while [[ "$MARKDOWN_SOURCE" =~ \[([^\]]*)\]\(([.\~][^\)]+)\) ]]; do
# pandoc used to html conversion does not seems to like ~ operator
# so we need to replace it manually with content of $HOME
file="${BASH_REMATCH[2]/\~/"$HOME"}"
# Flowded email sometime put description (or links containing space) on
# different lines. This two statements make sure all remain on the same
# line
file="${file// $'\n'/ }"
description=${BASH_REMATCH[1]// $'\n'/ }
if [[ ! -f "$file" ]]; then
>&2 echo "file $file not found"
exit 10
fi
sum=$(md5sum "$file")
sum=${sum%% *}
FILES["$file"]="$sum"
# replace filename with cid in out markdown source
MARKDOWN_SOURCE="${MARKDOWN_SOURCE//"${BASH_REMATCH[1]}"/"$description"}"
MARKDOWN_SOURCE="${MARKDOWN_SOURCE//"${BASH_REMATCH[2]}"/"cid:$sum"}"
done
}
Le principe général de cette fonction est simple: récupérer les données passées par l’entrée standard dans la variable MARKDOWN_SOURCE.
La boucle while permet de remplacer l’ensemble des liens markdown vers des fichiers locaux par des identifiants uniques.
Ces identifiants sont définies en calculant la somme MD5 du fichier pointé.
Pour chaque fichier, le chemin et la somme MD5 qui lui est associée est enregistré dans le tableau associatif FILES.
En Bash. Un tableau associatif se déclare comme suit:
declare -A FILES
J’utilise pas mal d’astuces données dans mon article à propos Bash avancé: barre de progression comme l’expansion de paramètre, ou encore l’opérateur de comparaison =~.
Conversion en HTML
C’est la fonction convert_to_html qui s’en charge:
PANDOC_HTML_OPTIONS=(-f markdown -t html5 --standalone --template=email.html)
# [...]
convert_to_html() {
local input_file output_file
input_file="$1"
output_file="$2"
pandoc "${PANDOC_HTML_OPTIONS[@]}" "$input_file" >"$output_file"
}
Cette fonction exécute simplement la commande pandoc avec les options adaptées.
Les commandes Neomutt
Ces commandes permettent à NeoMutt d’importer et de structurer les différents éléments de notre courriel.
Le but ici est de créer un fichier texte nommé commands contenant l’ensemble des commandes NeoMutt et leurs paramètres associés.
build_neomutt_command() {
local commands markdown_file html_file commands_file
markdown_file="$1"
html_file="$2"
commands_file="$3"
commands=('push ')
## Premiere partie
if [[ ! -z "${FILES[*]}" ]]; then
# replace markdown files with ones with cid
commands+=(
'<attach-file>'
"\"$markdown_file\"" # import du ficher markdown modifié par ce script
'<enter>'
'<toggle-disposition>'
'<toggle-unlink>' # le fichier markdown sera supprimé après envoi
'<move-up>' # le fichier markdown importé passe en première position
'2<enter>' # selection du fichier markdown original
'<detach-file>' # on le supprime
)
fi
## Seconde partie
commands+=(
'<attach-file>'
"\"$html_file\"" # import du fichier html créé par pandoc
'<enter>'
'<toggle-disposition>'
'<toggle-unlink>' # le fichier HTML sera supprimé après envoi
'<tag-entry>' # tag du fichier HTML
'<previous-entry>' # selection du fichier markdown
'<tag-entry>' # tag du fichier markdown
# Group the selected messages as alternatives
'<group-alternatives>'
)
## Troisième partie
for file in "${!FILES[@]}"; do
commands+=(
'<attach-file>'
"\"${file// /^v }\"" # on attache le fichier en échappant les espace avec ^v
'<enter>'
'<toggle-disposition>'
'<edit-content-id>^u' # ajout du Content-ID
"\"${FILES["$file"]}\""
'<enter>'
'<edit-description>^u' # ajout de la description
"\"${FILES["$file"]} - ${file##*/}\""
'<enter>'
'<tag-entry>' # tag de l'entrée fichier
)
done
## Quatrième partie
if [[ ! -z "${FILES[*]}" ]]; then
commands+=(
'<first-entry>'
'<tag-entry>' # tag de la partie multipart/alternative
'<group-related>' # tous tagués sont encapsulés dans un multipart/related
)
fi
printf "%s" "${commands[@]}" >"$commands_file"
}
Premiere partie
Si notre variable FILES contient des données, alors il faut remplacer le contenu en texte brut original par celui créé par le script — qui contient les références cid: et non les chemins locaux.
Il faut aussi penser à supprimer le contenu originel pour ne garder qu’une seule version en texte brut.
Seconde partie
Il faut intégrer le fichier HTML, sélectionner les version texte brut et HTML et les marquées comme multipart/alternative
Troisième partie
Si des fichiers sont référencés dans la variable FILE, alors il faut les intégrer au courriel.
Pour chacun d’eux, il faut non seulement les attacher mais aussi définit leur Content-ID.
Pour faciliter la vie de ceux qui lisent le message en texte brut 1, ajouter une description contenant l’identifiant du fichier mais aussi son nom.
Et enfin NeoNutt tague 2 le fichier dans la liste.
Quatrième partie
Si des fichiers on été intégrés, alors la première entrée est taguée (qui correspond au contenu multipart/alternative).
Comme les fichiers intégrés à l’étape 3 sont déjà tagués, il nous reste juste à définir tous ces éléments comme du contenu multipart/related
Macro Neomutt
J’ai choisi une exécution manuelle du script via un raccourci clavier depuis la fenêtre Compose.
macro compose Y \
"<first-entry><pipe-entry>$HOME/.config/neomutt/md-multipart-convert.sh -o $XDG_RUNTIME_DIR/neomutt<enter>\
<enter-command>source $XDG_RUNTIME_DIR/neomutt/commands<enter>\
<shell-escape>rm $XDG_RUNTIME_DIR/neomutt/commands<enter>"
La première entrée des éléments (le contenu texte brut) et l’envoi via un pipe au script qui écrira les 3 fichiers (markdown, HTML et commandes) dans le dossier $XDG_RUNTIME_DIR/neomutt.
Ensuite, Neomutt source le fichier de commande et le supprime.
Rappelez vous que les fichiers HTML et markdown seront supprimés automatiquement après envoi du courriel par Neomutt grace à <toggle-unlink> présente dans le fichier de commandes.
Il ne reste plus qu’à envoyer le message!
Tester
Voici le message au format markdown écrit directement dans l’instance de mon éditeur de texte ouvert par NeoMutt:
## Ceci est un message de test
Il doit me permettre de *tester **Ce qui fonctionne** avec le markdown*
comme par exemple [les liens](https://xieme-art.org)
Est-ce que *l'emphase* et **l'emphase forte** fonctionne comme attendu?
1. une
2. liste
3. ordonnée
* et
* une liste
* non ordonnée
### Un sous titre

Et enfin un autre [lien][lien]
[lien]: https://giroll.org
--
Yorick Barbanneau
Une fois la macro exécutée par NeoMutt depuis la fenêtre de composition avec le raccourci Shift+y, voici le contenu du courriel:
1 | I multipart/related - <no description>
2 | I ├─>multipart/alternative - <no description>
3 | I │ ├─>text/plain - 911bf544-bb3d-4f56-b8c6-2245cab4b98a.txt
4 | I │ └─>text/html - 911bf544-bb3d-4f56-b8c6-2245cab4b98a.html
6 | I └─> image/jpeg - b2ce6ec99074ffbc9b30c785bf63af69 - screenshot.jpg
Et voici le résultat une fois reçu dans Thunderbird:

La mise en forme du texte est conforme au code markdown et l’image est bien affichée. D’ailleurs si vous voulez la voir en entier vous pouvez ouvrir le fichier courriel d’exemple, mais je suis sûr que vous savez n’est ce pas?
Enfin dans NeoMutt, la version markdown est affichée, cependant le lien vers l’image est remplacé par son identifiant.

Pour avoir accès à l’image, il suffit d’afficher la liste des différentes parties du message (v depuis le pager ou l’index) et de sélectionner l’image correspondant à l’identifiant dans la liste.

Bonus: coloration syntaxique du markdown
Histoire d’égayer un peu l’affichage du markdown dans le pager, voici les règles que j’utilise, et que vous pouvez voir en action sur la capture d’écran de Neomutt:
# When possible, these regular expressions attempt to match http://spec.commonmark.org/
## Weak
# ~~~ Horizontal rules ~~~
color body color08 default "([[:space:]]*[-+=#*~_]){3,}[[:space:]]*"
# `Code` span
color body color02 default "(^|[[:space:][:punct:]])\`[^\`]+\`([[:space:][:punct:]]|$)"
color body color02 default "^\`\`\`.*"
# Titles
color body bold color06 default "^[#]{2} .*$"
color body bold color04 default "^[#]{3} .*$"
color body bold color13 default "^[#]{4} .*$"
# Emphasis
color body bold color03 default '(\*\*|__)[^[:space:]].*?\1'
color body italic color03 default '(^|[^*])\*[^*[:space:]][^*]*\*'
# Bold + italic
color body italic brightcolor03 default '(\*\*\*|___)[^[:space:]].*?\1'
# Ordered an unordered list
color body color01 default "^[ ]*[*-] "
color body italic color01 default "^[ ]*[0-9]+[.] "
# Links description
color body color17 default "!\\[(.*)\\]"
color body color16 default "\\[(.*)\\]:?"
## URI
color body italic color04 default "cid:[a-zA-Z0-9]+"
color body italic color04 default "[a-z][a-z0-9+-]*://[\-\.,/%~_:?&=\#a-zA-Z0-9]+"
# Email addresses
color body italic color04 default "mailto:"
color body italic color06 default "[\-\.+_a-zA-Z0-9]+@[\-\.a-zA-Z0-9]+"
Les couleurs sont définies en fonction du thème que j’utilise pour mon terminal (Base16).
En conclusion
Trouver la bonne configuration pour envoyer ce genre de courriels m’a pris du temps. J’ai du d’abord comprendre le fonctionnement des différents type de contenu dans un courrier électronique, c’est pourquoi j’ai choisi d’en faire une synthèse dans cet article. J’ai aussi potassé la documentation Neomutt qui est claire, bien fournie et accessible.
Cet article de Jonathan Hogson et celui-ci écrit par Tom Wemyss m’ont aussi beaucoup aidé.
En français, je vous conseilles un petit tour par le blog de Stéphane Bortzmeyer notamment cette anecdote intéressante sur l’usage du multipart/alternative, celui-ci à propos de la RFC8255 ou encore cet article à propos de la RFC2046
Merci à Heuzef, Candenza et Yishan pour les nombreuse relectures, corrections et conseils. Image d’entête: logo de NeoMutt par Malcolm Locke.