Send email from Java and Spring Boot

Send email from any Java app with Groundmail: over SMTP with Jakarta Mail (the successor to JavaMail) or Spring Boot's JavaMailSender, or over the REST API with java.net.http.HttpClient or Spring's RestClient. There's no Groundmail library to add. The examples need Java 17 or later; the Spring examples work on Spring Boot 3.2 and later.

SMTP (Jakarta Mail, JavaMailSender) REST API (HttpClient, RestClient)
Best for Existing code that already sends over SMTP New code, retries, delivery status
Credential SMTP credential (gmsmtp_…) API key (gm_…)
Response 250 from the server Message ID (msg_…) and status, structured errors
Idempotency X-Groundmail-Idempotency-Key header Idempotency-Key header

Both use the same verified domains, suppressions, limits and webhooks.


Before you start

  1. Add and verify your sending domain. To test before DNS is set up, send from noreply@mail.groundmail.dev to your own account email.
  2. Create the credential you need at app.groundmail.dev: an SMTP credential under SMTP, or an API key under API keys. Use a Send only key for app code.
  3. Keep it in an environment variable or your secret manager, never in source or application.properties checked into git.

SMTP settings for every section below:

Setting Value
Host smtp.groundmail.dev
Port 587
Security STARTTLS (required)
Username groundmail
Password Your SMTP credential (gmsmtp_…)

Send with Jakarta Mail

JavaMail moved to the Eclipse Foundation as Jakarta Mail, and the package changed from javax.mail to jakarta.mail. Add the API and its implementation, Eclipse Angus Mail:

<dependency>
  <groupId>org.eclipse.angus</groupId>
  <artifactId>angus-mail</artifactId>
  <version>2.0.5</version>
</dependency>

Gradle: implementation("org.eclipse.angus:angus-mail:2.0.5"). It brings in jakarta.mail-api.

import jakarta.mail.Authenticator;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.PasswordAuthentication;
import jakarta.mail.Session;
import jakarta.mail.Transport;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMessage;
import jakarta.mail.internet.MimeMultipart;
import java.io.UnsupportedEncodingException;
import java.util.Properties;

public class SendReceipt {

    public static void main(String[] args) throws MessagingException, UnsupportedEncodingException {
        Properties props = new Properties();
        props.put("mail.smtp.host", "smtp.groundmail.dev");
        props.put("mail.smtp.port", "587");
        props.put("mail.smtp.auth", "true");
        props.put("mail.smtp.starttls.enable", "true");
        props.put("mail.smtp.starttls.required", "true");
        props.put("mail.smtp.connectiontimeout", "10000");
        props.put("mail.smtp.timeout", "30000");

        Session session = Session.getInstance(props, new Authenticator() {
            @Override
            protected PasswordAuthentication getPasswordAuthentication() {
                return new PasswordAuthentication("groundmail", System.getenv("GROUNDMAIL_SMTP_PASSWORD"));
            }
        });

        MimeMessage message = new MimeMessage(session);
        message.setFrom(new InternetAddress("billing@yourdomain.com", "Acme"));
        message.setRecipients(Message.RecipientType.TO, InternetAddress.parse("user@example.com"));
        message.setSubject("Your receipt", "UTF-8");

        // Optional: Groundmail features over SMTP.
        message.setHeader("X-Groundmail-Idempotency-Key", "receipt-order-1042");
        message.setHeader("X-Groundmail-Tag", "type=receipt");

        MimeBodyPart text = new MimeBodyPart();
        text.setText("Thanks for your order.", "UTF-8");
        MimeBodyPart html = new MimeBodyPart();
        html.setContent("<p>Thanks for your order.</p>", "text/html; charset=UTF-8");
        message.setContent(new MimeMultipart("alternative", text, html));

        Transport.send(message);
    }
}

mail.smtp.starttls.required makes the client refuse to send if the connection can't be encrypted, rather than falling back to plain text. The X-Groundmail-* headers map to API features: tags, idempotency (24 hours) and X-Groundmail-Scheduled-At for scheduled sends. See SMTP relay.

Migrating old JavaMail code is mostly a find-and-replace of javax.mail with jakarta.mail, plus swapping the com.sun.mail:javax.mail dependency for angus-mail.

SMTP errors

Transport.send throws a MessagingException. The server checks the message after it's sent, so a rejection arrives as an SMTPSendFailedException (org.eclipse.angus.mail.smtp) whose getReturnCode() is the reply code:

Exception Meaning What to do
AuthenticationFailedException Wrong username or password, or a revoked credential Username must be groundmail; check the gmsmtp_… value
SMTPSendFailedException, code 451 Temporary: for example a new account's daily ramp-up limit Retry later. The message says when (retry after <time>)
SMTPSendFailedException, code 554 Permanent: for example From isn't on a verified domain, every recipient is suppressed, or the Free plan's daily limit Don't retry. getMessage() has the reason
MailConnectException Can't reach the server Check that outbound port 587 is open

Spring Boot: spring-boot-starter-mail

Add the starter:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-mail</artifactId>
</dependency>

Configure it in application.properties:

spring.mail.host=smtp.groundmail.dev
spring.mail.port=587
spring.mail.username=groundmail
spring.mail.password=${GROUNDMAIL_SMTP_PASSWORD}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true
spring.mail.properties.mail.smtp.connectiontimeout=10000
spring.mail.properties.mail.smtp.timeout=30000

Or application.yml:

spring:
  mail:
    host: smtp.groundmail.dev
    port: 587
    username: groundmail
    password: ${GROUNDMAIL_SMTP_PASSWORD}
    properties:
      mail.smtp.auth: true
      mail.smtp.starttls.enable: true
      mail.smtp.starttls.required: true

Spring Boot then provides a JavaMailSender bean. Inject it:

import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;

@Service
public class ReceiptMailer {

    private final JavaMailSender mailSender;

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

    public void sendReceipt(String to, String orderId, byte[] invoicePdf) throws MessagingException {
        MimeMessage message = mailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); // true = multipart

        helper.setFrom("billing@yourdomain.com");
        helper.setTo(to);
        helper.setSubject("Receipt for order " + orderId);
        helper.setText("Thanks for order " + orderId + ".", "<p>Thanks for order " + orderId + ".</p>");
        helper.addAttachment("invoice-" + orderId + ".pdf", new ByteArrayResource(invoicePdf), "application/pdf");

        // Optional: Groundmail features over SMTP.
        message.setHeader("X-Groundmail-Idempotency-Key", "receipt-" + orderId);
        message.setHeader("X-Groundmail-Tag", "type=receipt");

        mailSender.send(message);
    }
}

setText(plain, html) sends both a plain-text and an HTML part. For a plain-text email with no attachments, SimpleMailMessage is enough: set from, to, subject and text, then mailSender.send(message). Spring wraps SMTP failures in MailSendException and MailAuthenticationException; the cause carries the reply code as in the table above.


Send with the REST API and java.net.http.HttpClient

One POST https://api.groundmail.dev/emails per email, with the API key as a Bearer token. This client uses Jackson for JSON (com.fasterxml.jackson.core:jackson-databind), turns error responses into an exception you can branch on, and retries safely:

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;

public class GroundmailClient {

    @JsonInclude(JsonInclude.Include.NON_NULL)
    public record SendEmailRequest(
            String from,
            List<String> to,
            String subject,
            String html,
            String text,
            @JsonProperty("reply_to") String replyTo,
            Map<String, String> tags) {}

    @JsonIgnoreProperties(ignoreUnknown = true)
    public record SendEmailResponse(String id, String status, Boolean test) {}

    public static class GroundmailException extends RuntimeException {
        public final int status;
        public final String code;
        public final String hint;
        public final Duration retryAfter;

        GroundmailException(int status, String code, String message, String hint, Duration retryAfter) {
            super(message);
            this.status = status;
            this.code = code;
            this.hint = hint;
            this.retryAfter = retryAfter;
        }

        /** 429 (except the daily ramp-up limit), 500, 502 and 503 are safe to retry with the same key. */
        public boolean isRetryable() {
            return (status == 429 && !"daily_ramp_limit".equals(code))
                    || status == 500 || status == 502 || status == 503;
        }
    }

    private static final URI EMAILS = URI.create("https://api.groundmail.dev/emails");

    private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
    private final ObjectMapper json = new ObjectMapper();
    private final Supplier<String> bearer;

    /** Pass () -> System.getenv("GROUNDMAIL_API_KEY"), or a token supplier (see Azure below). */
    public GroundmailClient(Supplier<String> bearer) {
        this.bearer = bearer;
    }

    public SendEmailResponse send(SendEmailRequest email, String idempotencyKey)
            throws IOException, InterruptedException {
        HttpRequest request = HttpRequest.newBuilder(EMAILS)
                .header("Authorization", "Bearer " + bearer.get())
                .header("Content-Type", "application/json")
                .header("Idempotency-Key", idempotencyKey)
                .timeout(Duration.ofSeconds(30))
                .POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(email)))
                .build();

        HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() / 100 == 2) {
            return json.readValue(response.body(), SendEmailResponse.class);
        }
        throw toException(response);
    }

    /** Retries rate limits, 5xx and network errors with the same idempotency key. */
    public SendEmailResponse sendWithRetry(SendEmailRequest email, String idempotencyKey)
            throws IOException, InterruptedException {
        int maxAttempts = 5;
        for (int attempt = 1; ; attempt++) {
            Duration wait = Duration.ofSeconds(1L << (attempt - 1)); // 1s, 2s, 4s, 8s
            try {
                return send(email, idempotencyKey);
            } catch (GroundmailException e) {
                if (!e.isRetryable() || attempt == maxAttempts) throw e;
                if (e.retryAfter != null) wait = e.retryAfter;
            } catch (IOException e) {
                // Includes timeouts: the email may or may not have been accepted,
                // and retrying with the same key is safe either way.
                if (attempt == maxAttempts) throw e;
            }
            Thread.sleep(wait.toMillis());
        }
    }

    private GroundmailException toException(HttpResponse<String> response) {
        String code = null, hint = null, message = "Groundmail returned " + response.statusCode();
        try {
            JsonNode root = json.readTree(response.body());
            // Errors look like {"error": {"code", "message", "hint", "docs_url"}}.
            JsonNode error = root.path("error").isObject() ? root.path("error") : root;
            code = error.path("code").asText(null);
            hint = error.path("hint").asText(null);
            message = error.path("message").asText(message);
        } catch (IOException ignored) {
            // Not JSON (for example a proxy error page): keep the status line.
        }
        Duration retryAfter = response.headers().firstValue("Retry-After")
                .filter(s -> s.trim().matches("\\d+"))
                .map(s -> Duration.ofSeconds(Long.parseLong(s.trim())))
                .orElse(null);
        return new GroundmailException(response.statusCode(), code, message, hint, retryAfter);
    }
}

Send with it:

GroundmailClient groundmail = new GroundmailClient(() -> System.getenv("GROUNDMAIL_API_KEY"));

try {
    GroundmailClient.SendEmailResponse result = groundmail.sendWithRetry(
            new GroundmailClient.SendEmailRequest(
                    "Acme <billing@yourdomain.com>",
                    List.of("user@example.com"),
                    "Your receipt",
                    "<p>Thanks for your order.</p>",
                    "Thanks for your order.",
                    null,
                    Map.of("type", "receipt")),
            "receipt-order-1042");
    System.out.println(result.id() + " " + result.status()); // msg_… sent
} catch (GroundmailClient.GroundmailException e) {
    if (e.status == 422) {
        // Every recipient is suppressed or opted out. Don't retry; flag the address instead.
    } else {
        // 401/403: wrong key, unverified From domain or missing scope. Fix the config.
        System.err.println(e.status + " " + e.code + ": " + e.getMessage() + " (" + e.hint + ")");
    }
}

Request fields are snake_case: from, to, subject, html, text, reply_to, cc, bcc, tags, attachments, scheduled_at, template. The full list is in the Emails API and the OpenAPI spec. Attachments are objects with name, content_type and base64 data (Base64.getEncoder().encodeToString(bytes)).

Use an Idempotency-Key built from what the email is for (an order ID, a reset token ID). A retry with the same key and body returns the original response instead of sending twice, for 24 hours.


Spring Boot: RestClient

In Spring Boot 3.2 and later, RestClient is the idiomatic blocking HTTP client. Spring Boot 3 includes it with spring-boot-starter-web; on Spring Boot 4, add spring-boot-starter-restclient.

groundmail.api-key=${GROUNDMAIL_API_KEY}
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;

@Service
public class GroundmailEmailService {

    @JsonInclude(JsonInclude.Include.NON_NULL)
    public record SendEmailRequest(
            String from,
            List<String> to,
            String subject,
            String html,
            String text,
            @JsonProperty("reply_to") String replyTo,
            Map<String, String> tags) {}

    public record SendEmailResponse(String id, String status, Boolean test) {}

    public record ErrorBody(ErrorDetail error) {}

    public record ErrorDetail(String code, String message, String hint) {}

    private final RestClient restClient;

    public GroundmailEmailService(RestClient.Builder builder, @Value("${groundmail.api-key}") String apiKey) {
        this.restClient = builder
                .baseUrl("https://api.groundmail.dev")
                .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
                .build();
    }

    public SendEmailResponse send(SendEmailRequest email, String idempotencyKey) {
        return restClient.post()
                .uri("/emails")
                .header("Idempotency-Key", idempotencyKey)
                .body(email)
                .retrieve()
                .body(SendEmailResponse.class);
    }

    /** Retries 429 and 5xx with the same idempotency key, honouring Retry-After. */
    public SendEmailResponse sendWithRetry(SendEmailRequest email, String idempotencyKey) throws InterruptedException {
        for (int attempt = 1; ; attempt++) {
            try {
                return send(email, idempotencyKey);
            } catch (RestClientResponseException e) {
                ErrorBody body = e.getResponseBodyAs(ErrorBody.class);
                String code = body != null && body.error() != null ? body.error().code() : null;
                int status = e.getStatusCode().value();
                boolean retryable = (status == 429 && !"daily_ramp_limit".equals(code))
                        || status == 500 || status == 502 || status == 503;
                if (!retryable || attempt == 5) throw e;

                String retryAfter = e.getResponseHeaders() == null ? null : e.getResponseHeaders().getFirst(HttpHeaders.RETRY_AFTER);
                long seconds = retryAfter != null && retryAfter.matches("\\d+") ? Long.parseLong(retryAfter) : 1L << (attempt - 1);
                Thread.sleep(seconds * 1000);
            }
        }
    }
}

On an error status, retrieve() throws a RestClientResponseException (HttpClientErrorException for 4xx, HttpServerErrorException for 5xx); getResponseBodyAs(ErrorBody.class) reads Groundmail's code, message and hint. Spring's message converters handle JSON, so this works with Jackson 2 (Spring Boot 3) and Jackson 3 (Spring Boot 4). Reactive apps can make the same call with WebClient: webClient.post().uri("/emails").header("Idempotency-Key", key).bodyValue(email).retrieve().bodyToMono(SendEmailResponse.class).


Handle errors

Every error is JSON with a stable code, a message and a hint (Errors). Branch on the status and code:

Status Code Meaning What to do
401 unauthorized Missing, wrong or revoked API key Fix the key. Don't retry
403 forbidden, insufficient_scope, domain_not_allowed From domain not verified, sandbox or plan restriction, or the key can't do this Read hint. Don't retry
400 validation_error A field is missing or malformed Fix the request. Don't retry
422 validation_error Every recipient is suppressed (details.suppressed) or opted out Don't retry to the same recipients
429 rate_limited Too many requests Wait Retry-After seconds, retry with the same Idempotency-Key
429 daily_ramp_limit A new account's daily ramp-up limit Wait until details.resets_at (next 00:00 UTC)
500, 502, 503 Not sent Retry with backoff and the same Idempotency-Key
504 upstream_error The email may still go out Check GET /emails before resending. A retry with the same key returns the same 504, never a duplicate

Both sendWithRetry methods above implement the retry rows. If you use Spring Retry or Resilience4j instead, keep the idempotency key fixed across attempts.


Test without sending

Test keys. Create a key with Test key ticked on the API keys page. It starts with gm_test_. A send with it runs every check a live send does (verified domain, suppressions, validation, idempotency) and fires the same webhooks, but delivers nothing and counts toward nothing. The response has "test": true. Use the test key in your dev and test profiles and the live key in production; the code doesn't change.

Simulator addresses give a specific outcome, with real webhooks, from a test key or a live key:

Send to Outcome
delivered@simulator.groundmail.dev Delivered
bounce@simulator.groundmail.dev Hard bounce
complaint@simulator.groundmail.dev Delivered, then a spam complaint
suppressed@simulator.groundmail.dev 422, as if the recipient were suppressed

Add a tag to tell tests apart: bounce+signup-42@simulator.groundmail.dev. SMTP credentials are always live, but simulator addresses work over SMTP too, so a JavaMailSender test can send to delivered@simulator.groundmail.dev.

An integration test that checks the 422 path (JUnit 5):

GroundmailClient.GroundmailException e = assertThrows(GroundmailClient.GroundmailException.class, () ->
        groundmail.send(new GroundmailClient.SendEmailRequest(
                "billing@yourdomain.com", List.of("suppressed@simulator.groundmail.dev"),
                "Suppression test", null, "Simulated.", null, null),
                "test-" + UUID.randomUUID()));

assertEquals(422, e.status);

More in Testing.


Azure: keyless auth with managed identity

On Azure (App Service, Container Apps, Functions, AKS), the app's managed identity can authenticate instead of an API key. Set it up as in Authenticate from Azure with managed identity, add com.azure:azure-identity, and give the client a token supplier:

import com.azure.core.credential.TokenRequestContext;
import com.azure.identity.DefaultAzureCredential;
import com.azure.identity.DefaultAzureCredentialBuilder;

DefaultAzureCredential credential = new DefaultAzureCredentialBuilder().build();
TokenRequestContext scope = new TokenRequestContext().addScopes("https://api.groundmail.dev/.default");

// getTokenSync returns a cached token until it is close to expiring.
GroundmailClient groundmail = new GroundmailClient(() -> credential.getTokenSync(scope).getToken());

The SMTP relay still needs an SMTP credential, because SMTP can't carry an Entra token.