Diesen Artikel habe ich 2008 geschrieben. Der Code lief damals, und er hat es über die Jahre in etliche Projekte geschafft, wenn ich den Kommentaren glauben darf. Heute kompiliert er nicht mehr. Die Pakete heißen anders, die Klasse hat einen Nebenläufigkeitsfehler, den ich damals nicht gesehen habe, und kein Mailserver der Welt nimmt noch eine unverschlüsselte Verbindung ohne Anmeldung an.

Also eine neue Fassung. Die Aufgabe ist dieselbe geblieben: eine Klasse, die aus der ganzen Anwendung heraus E-Mails verschickt, mit Anhängen, und ohne dass der Benutzer auf den SMTP-Server wartet.

Was aus JavaMail geworden ist

JavaMail wurde an die Eclipse Foundation übergeben und heißt seitdem Jakarta Mail. Mit Jakarta EE 9 wanderte der komplette Namensraum von javax.mail nach jakarta.mail, und javax.activation entsprechend nach jakarta.activation. Die Implementierung selbst ist ein eigenes Projekt geworden: Eclipse Angus, vormals com.sun.mail.

Für ein Projekt ohne Application Server braucht man beides, die API und die Implementierung:

<dependency>
    <groupId>jakarta.mail</groupId>
    <artifactId>jakarta.mail-api</artifactId>
    <version>2.1.3</version>
</dependency>
<dependency>
    <groupId>org.eclipse.angus</groupId>
    <artifactId>angus-mail</artifactId>
    <version>2.0.3</version>
    <scope>runtime</scope>
</dependency>Code language: HTML, XML (xml)

Wer von der alten Fassung migriert, kommt bei den Imports mit Suchen und Ersetzen durch. Die API ist bis auf den Namensraum unverändert geblieben.

Die Datenklassen

2008 waren das zwei Klassen mit Gettern und Settern über zwei Bildschirmseiten. Records erledigen das in sechs Zeilen und geben die Unveränderlichkeit gratis dazu.

import java.nio.file.Path;
import java.util.List;

public record Attachment(String fileName, String mimeType, Path file) {}Code language: Java (java)
import java.util.List;

public record Mail(
        String from,
        List<String> to,
        List<String> cc,
        List<String> bcc,
        String subject,
        String htmlBody,
        List<Attachment> attachments) {

    // Kompakter Konstruktor: Kopien anlegen, damit die Mail wirklich unveränderlich ist,
    // und null für die optionalen Felder zulassen.
    public Mail {
        to = List.copyOf(to);
        cc = cc == null ? List.of() : List.copyOf(cc);
        bcc = bcc == null ? List.of() : List.copyOf(bcc);
        attachments = attachments == null ? List.of() : List.copyOf(attachments);
    }

    public static Mail of(String from, String to, String subject, String htmlBody) {
        return new Mail(from, List.of(to), null, null, subject, htmlBody, null);
    }
}Code language: Java (java)

Die Zugangsdaten gehören nicht in die Versandklasse, sondern in eine eigene Konfiguration, die von außen kommt:

public record MailConfig(String host, int port, String user, String password) {}Code language: Java (java)

Der Versand

Das Original startete im Konstruktor eines Singletons einen Thread, der alle 600 Millisekunden nachsah, ob in einer LinkedList etwas liegt. Beides braucht man nicht mehr. Ein ExecutorService macht dasselbe, nur ohne aktives Warten und mit einem Weg, ihn wieder zu beenden.

import jakarta.activation.DataHandler;
import jakarta.activation.FileDataSource;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.Part;
import jakarta.mail.Session;
import jakarta.mail.Transport;
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMessage;
import jakarta.mail.internet.MimeMultipart;

import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public final class MailSender implements AutoCloseable {

    private final MailConfig config;
    private final Session session;
    private final ExecutorService workers;

    public MailSender(MailConfig config) {
        this(config, 2);
    }

    /**
     * @param parallelConnections wie viele Mails gleichzeitig rausgehen dürfen.
     *        Klein halten: der Engpass ist der SMTP-Server, und die meisten
     *        Anbieter drosseln bei zu vielen parallelen Verbindungen.
     */
    public MailSender(MailConfig config, int parallelConnections) {
        this.config = config;
        this.session = Session.getInstance(smtpProperties(config));
        this.workers = Executors.newFixedThreadPool(parallelConnections);
    }

    private static Properties smtpProperties(MailConfig config) {
        Properties props = new Properties();
        props.put("mail.smtp.host", config.host());
        props.put("mail.smtp.port", String.valueOf(config.port()));
        props.put("mail.smtp.auth", "true");
        props.put("mail.smtp.starttls.enable", "true");
        props.put("mail.smtp.starttls.required", "true");
        // Ohne Timeouts hängt der Thread an einem stummen Server unbegrenzt.
        props.put("mail.smtp.connectiontimeout", "10000");
        props.put("mail.smtp.timeout", "10000");
        props.put("mail.smtp.writetimeout", "10000");
        return props;
    }

    /** Übergibt die Mail an den Worker-Pool. Das Future meldet Erfolg oder Fehler. */
    public CompletableFuture<Void> send(Mail mail) {
        return CompletableFuture.runAsync(() -> sendBlocking(mail), workers);
    }

    void sendBlocking(Mail mail) {
        try (Transport transport = session.getTransport("smtp")) {
            MimeMessage message = compose(mail);
            transport.connect(config.user(), config.password());
            transport.sendMessage(message, message.getAllRecipients());
        } catch (MessagingException e) {
            throw new MailDeliveryException(
                    "Mail \"" + mail.subject() + "\" konnte nicht versendet werden", e);
        }
    }

    private MimeMessage compose(Mail mail) throws MessagingException {
        MimeMessage message = new MimeMessage(session);
        message.setFrom(new InternetAddress(mail.from()));
        message.setRecipients(Message.RecipientType.TO, addresses(mail.to()));
        if (!mail.cc().isEmpty()) {
            message.setRecipients(Message.RecipientType.CC, addresses(mail.cc()));
        }
        if (!mail.bcc().isEmpty()) {
            message.setRecipients(Message.RecipientType.BCC, addresses(mail.bcc()));
        }
        message.setSubject(mail.subject(), StandardCharsets.UTF_8.name());
        message.setSentDate(new Date());

        MimeBodyPart body = new MimeBodyPart();
        body.setContent(mail.htmlBody(), "text/html; charset=UTF-8");

        // "mixed" ist der richtige Subtyp für Text plus Anhänge.
        // "alternative" meint zwei Fassungen desselben Inhalts, etwa Text und HTML.
        MimeMultipart content = new MimeMultipart("mixed");
        content.addBodyPart(body);

        for (Attachment attachment : mail.attachments()) {
            content.addBodyPart(bodyPart(attachment));
        }

        message.setContent(content);
        message.saveChanges();
        return message;
    }

    private static MimeBodyPart bodyPart(Attachment attachment) throws MessagingException {
        MimeBodyPart part = new MimeBodyPart();
        part.setDataHandler(new DataHandler(new FileDataSource(attachment.file().toFile())));
        part.setFileName(attachment.fileName());
        part.setHeader("Content-Type", attachment.mimeType());
        part.setDisposition(Part.ATTACHMENT);
        return part;
    }

    private static InternetAddress[] addresses(List<String> values) throws AddressException {
        return InternetAddress.parse(String.join(",", values));
    }

    @Override
    public void close() {
        workers.shutdown();
        try {
            if (!workers.awaitTermination(30, TimeUnit.SECONDS)) {
                workers.shutdownNow();
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            workers.shutdownNow();
        }
    }
}Code language: Java (java)

Dazu noch die Ausnahme, damit ein fehlgeschlagener Versand beim Aufrufer ankommt und nicht bloß im Log verschwindet:

public class MailDeliveryException extends RuntimeException {

    public MailDeliveryException(String message, Throwable cause) {
        super(message, cause);
    }
}Code language: Java (java)

Aufrufen

MailConfig config = new MailConfig(
        System.getenv("SMTP_HOST"),
        587,
        System.getenv("SMTP_USER"),
        System.getenv("SMTP_PASSWORD"));

try (MailSender sender = new MailSender(config)) {

    Mail rechnung = new Mail(
            "versand@example.com",
            List.of("kunde@example.com"),
            null,
            null,
            "Ihre Rechnung",
            "<p>Die Rechnung liegt im Anhang.</p>",
            List.of(new Attachment("rechnung.pdf", "application/pdf",
                    Path.of("/var/data/rechnung-4711.pdf"))));

    sender.send(rechnung)
          .exceptionally(error -> {
              logger.error("Versand fehlgeschlagen", error);
              return null;
          });
}Code language: Java (java)

Das try-Block-Ende ruft close() auf, und close() wartet bis zu 30 Sekunden auf die laufenden Versände. Die Mail geht also raus, auch wenn das Programm direkt danach endet.

Was an der Fassung von 2008 nicht mehr trägt

Der interessantere Teil ist, was am alten Code falsch war. Manches war schon damals falsch, mir aber nicht aufgefallen.

Die Queue war nicht threadsicher. Eine LinkedList, in die der Anwendungsthread schreibt und aus der ein zweiter Thread liest, ohne jede Synchronisierung. Das läuft unter Last nicht nur langsam, es verliert Mails, und es tut das unauffällig. Eine BlockingQueue hätte gereicht; der ExecutorService bringt sie mit.

Der Thread ließ sich nicht beenden. Eine while (true)-Schleife, gestartet im Konstruktor eines Singletons. Bei einem Redeploy im Servlet-Container bleibt der Thread hängen und hält den alten Classloader fest. Das ist die klassische Ursache für OutOfMemoryError: Metaspace nach dem dritten Deployment.

Das aktive Warten. Alle 600 Millisekunden nachsehen, ob etwas da ist, heißt: im Leerlauf ständig aufwachen, und im Ernstfall bis zu 600 Millisekunden Verzögerung. Eine blockierende Queue wacht auf, wenn etwas kommt.

Session.getDefaultInstance liefert eine JVM-weite Instanz, die derjenige prägt, der sie zuerst anfordert. Zwei Anwendungen im selben Container, und die zweite bekommt die Properties der ersten. Session.getInstance legt jedes Mal eine eigene an.

Die Felder lagen auf der Instanz. msg, content, addressFrom und die anderen gehören in die Methode. Solange genau ein Mailer-Thread läuft, geht das gut. Wer einen zweiten dazunimmt, bekommt Mails mit vermischten Absendern, und er wird lange suchen.

new MimeMultipart("alternativ") ist gleich zweifach daneben: ein Tippfehler, und der falsche Subtyp. alternative sagt dem Client, hier kämen mehrere Fassungen desselben Inhalts, aus denen er eine aussuchen soll. Anhänge gehören unter mixed.

Kein TLS, keine Anmeldung, kein Timeout. 2008 gab es genug Server, die auf Port 25 alles annahmen, was aus dem eigenen Netz kam. Heute bekommt man ohne mail.smtp.auth und STARTTLS auf Port 587 gar keine Verbindung mehr.

Die Zugangsdaten standen als Feldinitialisierung im Quelltext. Dazu muss man nichts weiter sagen.

Im Beispielaufruf steckten außerdem zwei Fehler, die niemand gemeldet hat: MailServer.getInstance() statt MailSender, und die Adressen in der falschen Reihenfolge, weil der Konstruktor (sender, to, ...) erwartet, das Beispiel aber den Empfänger zuerst übergibt.

Wenn du Spring Boot nutzt

Dann schreibst du das alles nicht selbst. spring-boot-starter-mail bringt den JavaMailSender mit, und @Async erledigt die Nebenläufigkeit.

spring:
  mail:
    host: smtp.example.com
    port: 587
    username: ${SMTP_USER}
    password: ${SMTP_PASSWORD}
    properties:
      mail.smtp.auth: true
      mail.smtp.starttls.enable: trueCode language: YAML (yaml)
@Service
public class MailService {

    private final JavaMailSender mailSender;

    public MailService(JavaMailSender mailSender) {
        this.mailSender = mailSender;
    }

    @Async
    public void send(Mail mail) {
        mailSender.send(message -> {
            var helper = new MimeMessageHelper(message, true, "UTF-8");
            helper.setFrom(mail.from());
            helper.setTo(mail.to().toArray(String[]::new));
            helper.setSubject(mail.subject());
            helper.setText(mail.htmlBody(), true);
            for (Attachment attachment : mail.attachments()) {
                helper.addAttachment(attachment.fileName(), attachment.file().toFile());
            }
        });
    }
}Code language: Java (java)

@EnableAsync nicht vergessen, sonst läuft die Methode synchron und niemand merkt es, bis der erste Mailserver langsam antwortet.

Testen ohne Mailserver

GreenMail startet einen SMTP-Server im Test und lässt die verschickten Nachrichten wieder auslesen. Seit Version 2.1 setzt es auf Jakarta Mail 2.1 auf und passt damit zum Code oben.

@RegisterExtension
static GreenMailExtension greenMail =
        new GreenMailExtension(ServerSetupTest.SMTP);

@Test
void versendetMailMitAnhang() throws Exception {
    var config = new MailConfig("localhost", greenMail.getSmtp().getPort(), "user", "pw");
    try (var sender = new MailSender(config)) {
        sender.sendBlocking(Mail.of("a@example.com", "b@example.com", "Test", "<p>Hallo</p>"));
    }
    assertThat(greenMail.getReceivedMessages()).hasSize(1);
}Code language: Java (java)

Wann sich das alles nicht lohnt

Für eine Handvoll Mails am Tag ist die Klasse oben völlig ausreichend. Sobald es um Transaktionsmails in Serie geht, wird die eigene Queue zur Dauerbaustelle: Wiederholungen nach Zustellfehlern, Bounce-Verarbeitung, SPF, DKIM und DMARC, Reputation der Absender-IP. Das alles selbst zu bauen kostet mehr Zeit, als es je einspart. Ein Relay wie Amazon SES, Postmark oder Mailjet nimmt einem den Teil ab, und der Code oben schickt dann eben dorthin statt an den eigenen Server.

Die Klasse bleibt trotzdem nützlich, wenn eine Anwendung nur gelegentlich etwas verschickt und keine weitere Abhängigkeit dafür bekommen soll.

Mit Java eine E-Mail versenden: das Update auf Jakarta Mail
Tagged on:

3 thoughts on “Mit Java eine E-Mail versenden: das Update auf Jakarta Mail”

  • 2009-06-23 at 18:07
    Permalink

    Moin, in der Klasse “Mail” – “public void setAnhaenge(List; anhaenge)” Ich denke mal ein Semikolon zu viel .-) Klasse “MailSender” unter “Die Anhänge” – “for (MailAnhang mailAnhang : dateien)” Netbeans zeigt mir dort ein Typenkonflikt an (ObjektListe). Ein Fehler? Wie lautet es richtig? Mit Gruß

    PS: Hoffentlich nun mit richtiger Anzeigeformatierung .-)

    Reply
    • 2009-06-24 at 09:53
      Permalink

      Danke für die Fehlermeldung, ich habe den Code gleich mal korrigiert.
      Entweder du nutzt Generics die seit Java 5 verfügbar sind. Oder Du musst die Objecte in der Liste noch auf MailAnhang casten.

      Reply

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.