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
- Add and verify your sending domain. To test before DNS is set up, send from
noreply@mail.groundmail.devto your own account email. - 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.
- Keep it in an environment variable or your secret manager, never in source or
application.propertieschecked 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=30000Or 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: trueSpring 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.
Related
- SMTP relay: settings and custom headers
- Emails API: every send field
- Errors: codes and recovery
- Testing: test keys and simulator addresses
- C# and .NET