Neomutt: envoyer des courriels multipart markdown+html

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:

  1. le conteneur parent multipart/mixed: il nous indique que le message contient plusieurs types de contenu (ici message en texte brut + pièce jointe);
  2. la première partie: le texte du message;
  3. 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/alternative définis par la RFC 2046 (section 5.1);
  • un même texte disponible en plusieurs langues grâce type multipart/multilingual dé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/alternative lui 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-ID permet de définir un identifiant unique au contenu. C’est cet identifiant qui sera utilisé dans les contenus qui la référencent.
  • Content-Description permet 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:

  1. parser le fichier markdown à la recherche de lien pointant vers des fichier locaux:
    1. en créant un identifiant unique pour chacun des fichiers;
    2. en remplaçant les liens vers ces fichiers par des références cid:<identifiant> dans le source markdown.
  2. convertir le source markdown en ficher en fichier HTML avec pandoc;
  3. 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

![une image pour illustrer](~/medias/images/screenshots/screenshot.jpg)

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:

Capture d'écran du logiciel Thunderbird montrant le contenu du message au format HTML

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.

Capture d'écran du logiciel NeoMutt montrant le contenu texte du message

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.

Capture d'écran de NeoMutt affichant la liste des parties du message

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.


  1. avec Mutt ou NeoMutt par exemple 

  2. en langage Neomutt, taguer signifie sélectionner pour ensuite appliquer un traitement pat lots