Mailpit : testez vos e-mails en Java et en Python

« Spammez sans remords : ici, aucun e-mail n’atteint de vrai destinataire. »

Beaucoup de projets finissent par envoyer des e-mails (inscription, mot de passe oublié, facture, etc.). L’e-mail reste le moyen le plus commun de notifier ses utilisateurs. Et tout développeur finit par se poser la même question : comment tester ça sans arroser de vrais utilisateurs ? Les réponses classiques sont toutes mauvaises : commenter l’appel d’envoi, rediriger vers sa propre boîte Gmail à coups d’alias +test, ou pire, croiser les doigts et regarder les logs. Mauvaises, car elles ne respectent pas un principe fondamental : on ne modifie pas un package entre les tests et la mise en production. Ce qui a été validé doit être ce qui est déployé (certains reconnaîtront le principe d’immutabilité des packages, aussi résumé par “Build once, deploy anywhere”).

Mailpit règle le problème avec une idée simple : un serveur SMTP de capture, accompagné d’une interface web et d’une API REST. Votre application lui parle comme à un vrai serveur de mail, il intercepte tout, et rien ne repart vers l’extérieur.

Et par rapport à notre principe d’immutabilité des packages, la différence entre les tests (avec mailpit) et la mise en production (avec un serveur smtp de production) se résume à un changement de configuration (comme si on avait affaire à des serveurs différents sur des environnements différents).

Un faux SMTP, une vraie boîte de réception

Mailpit écoute sur deux ports :

  • 1025 : le serveur SMTP. Pointez-y votre application ; dans sa configuration par défaut, il n’exige ni authentification ni chiffrement (les deux existent en option si vous voulez tester ce chemin-là aussi).
  • 8025 : l’interface web et l’API REST. Chaque e-mail capturé y apparaît en temps réel, avec son rendu HTML, sa version texte, ses en-têtes et ses pièces jointes.

Par défaut, aucun message n’est remis à son destinataire réel : un e-mail envoyé à pdg@grandclient.com finit dans l’interface, pas dans sa boîte. (Mailpit sait aussi relayer ou transférer des messages vers un vrai serveur SMTP - des modes comme --smtp-relay-all existent - mais uniquement si vous les configurez explicitement.) C’est toute la différence avec un « mode sandbox » d’un fournisseur d’envoi - il n’y a pas de compte, pas de quota d’envoi ni de clé d’API à gérer.

Lancer Mailpit

Mailpit est écrit en Go : c’est un binaire unique, sans dépendance.

Pour MacOS, cela se passe via Homebrew :

bash
brew install mailpit
mailpit

Vous pouvez aussi passer par Docker, sans rien installer :

bash
docker run --rm \
  -p 127.0.0.1:8025:8025 \
  -p 127.0.0.1:1025:1025 \
  axllent/mailpit:v1.30.6

Pour les autres OS, vous pouvez télécharger le binaire pour Linux ou Windows depuis les releases GitHub.

Ensuite, ouvrez http://localhost:8025 : la boîte de réception est vide et n’attend que vos e-mails de test.

Envoyer un e-mail

Aucune bibliothèque spéciale : Mailpit est un serveur SMTP standard, votre code d’envoi habituel fonctionne tel quel. Il suffit de viser localhost:1025.

package fr.juery;

import jakarta.mail.Message;
import jakarta.mail.Session;
import jakarta.mail.Transport;
import jakarta.mail.internet.MimeMessage;

import java.util.Properties;

public class Example01SendEmail {

    public static void main(String[] args) throws Exception {
        Properties props = new Properties();
        props.put("mail.smtp.host", "localhost");
        props.put("mail.smtp.port", "1025");

        Session session = Session.getInstance(props);
        MimeMessage message = new MimeMessage(session);
        message.setFrom("noreply@monapp.local");
        message.setRecipients(Message.RecipientType.TO, "alice@example.com");
        message.setSubject("Welcome !");
        message.setText("Your account is ready.");

        Transport.send(message);

        System.out.println("Example 1: e-mail sent to Mailpit via localhost:1025");
    }
}

Rechargez localhost:8025 : le message est là, rendu comme dans un vrai client mail.

Dans un vrai projet

En général, on ne code pas le SMTP à la main (sauf si on est dans le cas d’un script ou un batch, par exemple) : on configure plutôt le framework de notre application.

Spring Boot

JavaMailSender ne voit pas la différence - seule la configuration change :

spring:
  mail:
    host: localhost
    port: 1025

Le profil de production garde son vrai relais SMTP ; le profil de dev n’enverra jamais rien à personne.

Django

Même principe dans les settings de développement :

EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "localhost"
EMAIL_PORT = 1025

Et pour Flask, c’est identique : quelle que soit la bibliothèque d’envoi (Flask-Mail, etc.), pointez-la vers localhost:1025 et le tour est joué.

Automatiser le lancement de Mailpit

Lancer mailpit à la main, à chaque tests, est assez fastidieux, vous pouvez être sûr de l’oublier une fois sur deux. Voici deux approches pour que l’outil fasse partie du projet plutôt que de votre mémoire.

Avec Docker Compose

Si le projet a déjà un compose.yml (pour lancer l’application dans un conteneur Docker, par exemple), vous pouvez ajouter Mailpit comme un service en plus :

compose.yml
services:
  app:
    build: .
    environment:
      SMTP_HOST: mailpit
      SMTP_PORT: 1025
    ports:
      - "8080:8080"

  mailpit:
    image: axllent/mailpit:v1.30.6
    ports:
      - "127.0.0.1:8025:8025"

Un docker compose up -d et le tour est joué, toute la stack de dev démarre d’un coup.

Côté stockage, Mailpit garde ses messages dans une base SQLite et ne conserve par défaut que les 500 derniers. Avec le conteneur de cet exemple, rien n’est persisté : supprimer le conteneur supprime aussi la base - en dev comme en CI, c’est généralement exactement ce qu’on veut (montez un volume et configurez le chemin de la base si vous tenez à la garder).

Avec Testcontainers

Pour les tests d’intégration, Testcontainers peut vous permettre d’aller plus loin : c’est le test lui-même qui démarre le conteneur, et l’arrête à la fin. Les ports exposés par le conteneur (1025, 8025) sont mappés sur des ports de l’hôte attribués dynamiquement : plus rien à lancer avant mvn test ou pytest, et plus de collision quand deux builds tournent en parallèle sur le même agent de CI, puisqu’aucun test n’a besoin de s’approprier les ports 1025 ou 8025 de l’hôte.

@Testcontainers
@SpringBootTest
class RegistrationEmailTest {

    @Container
    static GenericContainer<?> mailpit =
        new GenericContainer<>(DockerImageName.parse("axllent/mailpit:v1.30.6"))
            .withExposedPorts(1025, 8025);

    @DynamicPropertySource
    static void mailProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.mail.host", mailpit::getHost);
        registry.add("spring.mail.port", () -> mailpit.getMappedPort(1025));
    }

    static String mailpitApi() {
        return "http://" + mailpit.getHost() + ":" + mailpit.getMappedPort(8025);
    }
}

Côté dépendances, c’est simple : org.testcontainers:junit-jupiter en Java, testcontainers[mailpit] en Python. Le MailpitContainer de l’exemple Python est un module communautaire de Testcontainers ; il en existe aussi un côté Java, mais le GenericContainer ci-dessus fait la même chose sans dépendre du module dédié. Dans tous les cas, la règle est la même : les ports côté hôte étant dynamiques, injectez-les dans la configuration du test (avec le @DynamicPropertySource côté Spring, ou la fixture côté pytest) au lieu de coder quoi que ce soit en dur - et cela vaut aussi pour l’API, d’où le mailpitApi() côté Java et le get_base_api_url() côté Python.

Vérifier les e-mails, à la main, puis en CI

L’interface web suffit pour le développement au quotidien. Mais Mailpit va plus loin et expose aussi une API REST, c’est elle qui rend l’outil vraiment intéressant : vos tests d’intégration peuvent vérifier qu’un e-mail est parti, à qui, et avec quel contenu. (L’API sait faire bien plus - envoyer, taguer, relayer des messages ; les exemples ci-dessous supposent qu’elle n’est pas protégée par authentification, comme dans la configuration par défaut.)

bash
# List the e-mails received by MailPit
curl http://localhost:8025/api/v1/messages

# Search for e-mails
curl "http://localhost:8025/api/v1/search?query=to:alice@example.com"

# Empty the inbox
curl -X DELETE http://localhost:8025/api/v1/messages

Grâce à cette API REST, nous pouvons alors écrire un test qui envoie un e-mail à un destinataire, puis fait une Recherche ciblée de l’e-mail avec une attente (à laquelle on adjoint un timeout, pour ne pas attendre l’e-mail indéfiniment).

private static final HttpClient HTTP = HttpClient.newHttpClient();

@Test
void welcomeEmailIsSent() {
    String recipient = "alice+" + UUID.randomUUID() + "@example.com";
    registrationService.register(recipient);

    await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
        JsonNode messages = searchMailpit("to:" + recipient);
        assertEquals(1, messages.size());
        assertEquals("Bienvenue !", messages.get(0).path("Subject").asText());
    });
}

private JsonNode searchMailpit(String query) throws Exception {
    return mailpitGet("/api/v1/search?query="
        + URLEncoder.encode(query, StandardCharsets.UTF_8)).path("messages");
}

private JsonNode mailpitGet(String path) throws Exception {
    HttpRequest req = HttpRequest.newBuilder(URI.create(mailpitApi() + path))
        .timeout(Duration.ofSeconds(1))
        .build();
    HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
    if (res.statusCode() != 200) {
        throw new AssertionError("Mailpit returned HTTP " + res.statusCode());
    }
    return new ObjectMapper().readTree(res.body());
}

Vérifier le contenu, pas seulement le sujet

La liste et la recherche ne renvoient qu’un résumé de chaque message. Pour le ccontenu complet (texte, HTML, en-têtes, pièces jointes), interrogez GET /api/v1/message/{ID} avec l’ID trouvé à l’étape précédente :

JsonNode detail = mailpitGet("/api/v1/message/" + email.path("ID").asText());

assertEquals(recipient, detail.path("To").get(0).path("Address").asText());
assertTrue(detail.path("Text").asText().contains("Votre compte est prêt"));

Pensez enfin à vider la boîte (DELETE /api/v1/messages) entre deux tests : la recherche ciblée protège de l’ordre des messages, pas d’une boîte qui gonfle indéfiniment quand tous les tests partagent la même instance.

Dans un pipeline Github Actions

Sous GitHub Actions, Mailpit se déclare comme un simple service Docker :

.github/workflows/tests.yml
jobs:
  tests:
    runs-on: ubuntu-latest
    services:
      mailpit:
        image: axllent/mailpit:v1.30.6
        ports:
          - 1025:1025
          - 8025:8025
    env:
      SMTP_HOST: localhost
      SMTP_PORT: "1025"
    steps:
    ...

Le job tourne ici directement sur le runner, donc le service est joignable sur localhost (d’où les variables d’environnement). Si vos steps s’exécutent dans un conteneur (container:), remplacez localhost par le nom du service mailpit (même logique que dans Compose). Les mêmes tests tournent alors en local et en CI, sans aucune bascule de configuration.

Et si vos tests utilisent déjà Testcontainers, ce bloc services devient superflu : un runner avec Docker suffit, le test démarre son propre Mailpit.

Et les alternatives ?

MailHog rendait le même service mais est aujourd’hui beaucoup moins actif ; Mailpit est une alternative moderne qui s’en inspire et reprend notamment ses ports 1025 (SMTP) et 8025 (HTTP) (ce qui facilite le remplacement) en ajoutant la recherche, un serveur POP3, la vérification HTML, le spam testing (via SpamAssassin), les webhooks ou encore le chaos testing.

Côté NodeJS, MailDev joue le même rôle si votre équipe préfère un outil npm à un binaire Go. Le principe (et la configuration côté application) reste globalement identique.

Un e-mail de test qui part chez un vrai utilisateur est un bug qu’on ne pardonne pas (n’est-ce pas Cédric de Crédit Agricole ?).

Mailpit transforme « croiser les doigts » en « ouvrir localhost:8025 » : par défaut, tout est capturé et rien n’est délivré, et l’API REST rend la vérification automatisable jusqu’en CI.