How to Send Emails in Java / Spring Boot (2026 Guide)

Most "how to send email in Spring Boot" tutorials stop at configuring spring-boot-starter-mail with Gmail SMTP credentials. That's fine for development. It's not fine when you need to send welcome emails, password resets, payment receipts, and onboarding sequences to real users.
This guide covers the full picture: using API-based providers with WebClient, Spring's dependency injection, Thymeleaf email templates, async sending with @Async, retry policies with Spring Retry, Stripe webhook handling, and production configuration. All examples use Spring Boot 3+ with Java 17+ records.
Pick an Email Provider
You have three solid options. Each code example below lets you switch between them.
- Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one API. If you're building a SaaS product, this is the simplest path because you won't need to glue together three different tools later. It also has built-in retries and native Stripe integration.
- Resend is a developer-friendly transactional email API. Clean DX, good docs, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
- SendGrid is the enterprise standard. Feature-rich, has an official Java SDK. Good if you need high volume and don't mind a bigger API surface.
Spring Mail vs API Providers
// Spring Mail: SMTP-based, you manage the server
@Autowired JavaMailSender mailSender;
SimpleMailMessage msg = new SimpleMailMessage();
msg.setTo("user@example.com");
msg.setSubject("Hello");
msg.setText("Body");
mailSender.send(msg);
// You handle: SMTP config, TLS, authentication, connection pooling
// API provider: one HTTP call, they handle everything
webClient.post().uri("/transactional/send")
.bodyValue(Map.of("to", email, "subject", subject, "body", html))
.retrieve().bodyToMono(Map.class).block();
// Provider handles: deliverability, retries, bounce processing, TLSUse Spring Mail if you're talking to an internal SMTP server. Use an API provider for everything else.
Add Dependencies
<!-- Spring WebClient for HTTP calls -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Thymeleaf for email templates -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency><!-- Resend Java SDK -->
<dependency>
<groupId>com.resend</groupId>
<artifactId>resend-java</artifactId>
<version>3.1.0</version>
</dependency>
<!-- Thymeleaf for email templates -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency><!-- SendGrid Java SDK -->
<dependency>
<groupId>com.sendgrid</groupId>
<artifactId>sendgrid-java</artifactId>
<version>4.10.2</version>
</dependency>
<!-- Thymeleaf for email templates -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>Configure your API key in application.properties:
email.provider=sequenzy
email.api-key=${SEQUENZY_API_KEY}
email.from-address=noreply@yourdomain.com
email.from-name=Your Appemail.provider=resend
email.api-key=${RESEND_API_KEY}
email.from-address=noreply@yourdomain.com
email.from-name=Your Appemail.provider=sendgrid
email.api-key=${SENDGRID_API_KEY}
email.from-address=noreply@yourdomain.com
email.from-name=Your AppConfiguration Class
Use Spring Boot's @ConfigurationProperties for type-safe config:
// src/main/java/com/example/config/EmailProperties.java
package com.example.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "email")
public record EmailProperties(
String provider,
String apiKey,
String fromAddress,
String fromName
) {
public String fromFormatted() {
return "%s <%s>".formatted(fromName, fromAddress);
}
}// Enable in your main application class
@SpringBootApplication
@EnableConfigurationProperties(EmailProperties.class)
public class Application { ... }Create the Email Service
Use an interface with provider-specific implementations. Spring DI handles the wiring.
// src/main/java/com/example/service/EmailService.java
package com.example.service;
public interface EmailService {
EmailResult send(String to, String subject, String body);
}
public record EmailResult(boolean success, String id, String error) {
public static EmailResult ok(String id) { return new EmailResult(true, id, null); }
public static EmailResult fail(String error) { return new EmailResult(false, null, error); }
}package com.example.service;
import com.example.config.EmailProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import java.util.Map;
@Service
public class SequenzyEmailService implements EmailService {
private static final Logger log = LoggerFactory.getLogger(SequenzyEmailService.class);
private final WebClient webClient;
public SequenzyEmailService(EmailProperties props) {
this.webClient = WebClient.builder()
.baseUrl("https://api.sequenzy.com/v1")
.defaultHeader("Authorization", "Bearer " + props.apiKey())
.defaultHeader("Content-Type", "application/json")
.build();
}
@Override
public EmailResult send(String to, String subject, String body) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> response = webClient.post()
.uri("/transactional/send")
.bodyValue(Map.of("to", to, "subject", subject, "body", body))
.retrieve()
.bodyToMono(Map.class)
.block();
String jobId = response != null ? String.valueOf(response.get("jobId")) : null;
log.info("Email sent to {}, subject: {}, jobId: {}", to, subject, jobId);
return EmailResult.ok(jobId);
} catch (WebClientResponseException.TooManyRequests e) {
log.warn("Rate limited sending email to {}", to);
return EmailResult.fail("Rate limited");
} catch (WebClientResponseException.Unauthorized e) {
log.error("Invalid API key for email send");
return EmailResult.fail("Invalid API key");
} catch (WebClientResponseException e) {
log.error("Email send failed ({}) to {}: {}", e.getStatusCode(), to, e.getResponseBodyAsString());
return EmailResult.fail(e.getMessage());
} catch (Exception e) {
log.error("Email send failed to {}: {}", to, e.getMessage());
return EmailResult.fail(e.getMessage());
}
}
}package com.example.service;
import com.example.config.EmailProperties;
import com.resend.Resend;
import com.resend.core.exception.ResendException;
import com.resend.services.emails.model.CreateEmailOptions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class ResendEmailService implements EmailService {
private static final Logger log = LoggerFactory.getLogger(ResendEmailService.class);
private final Resend resend;
private final String from;
public ResendEmailService(EmailProperties props) {
this.resend = new Resend(props.apiKey());
this.from = props.fromFormatted();
}
@Override
public EmailResult send(String to, String subject, String html) {
try {
var params = CreateEmailOptions.builder()
.from(from)
.to(to)
.subject(subject)
.html(html)
.build();
var response = resend.emails().send(params);
log.info("Email sent to {}, id: {}", to, response.getId());
return EmailResult.ok(response.getId());
} catch (ResendException e) {
log.error("Email send failed to {}: {}", to, e.getMessage());
return EmailResult.fail(e.getMessage());
}
}
}package com.example.service;
import com.example.config.EmailProperties;
import com.sendgrid.*;
import com.sendgrid.helpers.mail.Mail;
import com.sendgrid.helpers.mail.objects.Content;
import com.sendgrid.helpers.mail.objects.Email;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.IOException;
@Service
public class SendGridEmailService implements EmailService {
private static final Logger log = LoggerFactory.getLogger(SendGridEmailService.class);
private final SendGrid sg;
private final String fromAddress;
public SendGridEmailService(EmailProperties props) {
this.sg = new SendGrid(props.apiKey());
this.fromAddress = props.fromAddress();
}
@Override
public EmailResult send(String to, String subject, String html) {
try {
var from = new Email(fromAddress);
var toEmail = new Email(to);
var content = new Content("text/html", html);
var mail = new Mail(from, subject, toEmail, content);
var request = new Request();
request.setMethod(Method.POST);
request.setEndpoint("mail/send");
request.setBody(mail.build());
var response = sg.api(request);
if (response.getStatusCode() >= 400) {
log.error("SendGrid error ({}) for {}: {}", response.getStatusCode(), to, response.getBody());
return EmailResult.fail("SendGrid error: " + response.getStatusCode());
}
log.info("Email sent to {} via SendGrid", to);
return EmailResult.ok(null);
} catch (IOException e) {
log.error("Email send failed to {}: {}", to, e.getMessage());
return EmailResult.fail(e.getMessage());
}
}
}Thymeleaf Email Templates
Thymeleaf is Spring Boot's default template engine. Use it for type-safe, well-structured email HTML.
Create a template renderer:
// src/main/java/com/example/service/EmailTemplateService.java
package com.example.service;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.time.Year;
import java.util.Map;
@Service
public class EmailTemplateService {
private final TemplateEngine templateEngine;
public EmailTemplateService(TemplateEngine templateEngine) {
this.templateEngine = templateEngine;
}
public String render(String template, Map<String, Object> variables) {
Context context = new Context();
context.setVariables(variables);
context.setVariable("year", Year.now().getValue());
return templateEngine.process("emails/" + template, context);
}
}Create the templates:
<!-- src/main/resources/templates/emails/layout.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" th:fragment="layout(content)">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body style="margin:0;padding:0;background-color:#f6f9fc;font-family:sans-serif;">
<div style="max-width:480px;margin:40px auto;background:#fff;border-radius:8px;padding:40px;">
<div th:replace="${content}">Content goes here</div>
</div>
<div style="text-align:center;padding:20px;color:#9ca3af;font-size:12px;">
<p>© <span th:text="${year}">2026</span> Your App. All rights reserved.</p>
</div>
</body>
</html><!-- src/main/resources/templates/emails/welcome.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
th:replace="~{emails/layout :: layout(~{::content})}">
<body>
<div th:fragment="content">
<h1 style="font-size:24px;margin-bottom:16px;"
th:text="'Welcome, ' + ${name}">Welcome</h1>
<p style="font-size:16px;line-height:1.6;color:#374151;">
Your account is ready. Click below to get started.
</p>
<a th:href="${dashboardUrl}"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;
border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;
margin-top:16px;">
Go to Dashboard
</a>
</div>
</body>
</html><!-- src/main/resources/templates/emails/password-reset.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
th:replace="~{emails/layout :: layout(~{::content})}">
<body>
<div th:fragment="content">
<h2 style="font-size:20px;">Password Reset</h2>
<p style="font-size:16px;line-height:1.6;color:#374151;">
Click the link below to reset your password. This link expires in 1 hour.
</p>
<a th:href="${resetUrl}"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;
border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;
margin-top:16px;">
Reset Password
</a>
<p style="color:#6b7280;font-size:14px;margin-top:24px;">
If you didn't request this, ignore this email.
</p>
</div>
</body>
</html><!-- src/main/resources/templates/emails/receipt.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
th:replace="~{emails/layout :: layout(~{::content})}">
<body>
<div th:fragment="content">
<h2 style="font-size:20px;">Payment Received</h2>
<p style="font-size:16px;color:#374151;">Thanks for your payment.</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr>
<td style="padding:8px;border-bottom:1px solid #e5e7eb;">Plan</td>
<td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right;" th:text="${plan}">Pro</td>
</tr>
<tr>
<td style="padding:8px;font-weight:600;">Total</td>
<td style="padding:8px;text-align:right;font-weight:600;" th:text="${amount}">$49.99</td>
</tr>
</table>
<a th:href="${invoiceUrl}" style="color:#f97316;">View full invoice</a>
</div>
</body>
</html>REST Controller with Templates
// src/main/java/com/example/controller/EmailController.java
package com.example.controller;
import com.example.service.EmailResult;
import com.example.service.EmailService;
import com.example.service.EmailTemplateService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/email")
public class EmailController {
private final EmailService emailService;
private final EmailTemplateService templateService;
public EmailController(EmailService emailService, EmailTemplateService templateService) {
this.emailService = emailService;
this.templateService = templateService;
}
record WelcomeRequest(String email, String name) {}
@PostMapping("/send-welcome")
public ResponseEntity<EmailResult> sendWelcome(@RequestBody WelcomeRequest request) {
String html = templateService.render("welcome", Map.of(
"name", request.name(),
"dashboardUrl", "https://app.yoursite.com/dashboard"
));
EmailResult result = emailService.send(request.email(), "Welcome, " + request.name(), html);
return result.success()
? ResponseEntity.ok(result)
: ResponseEntity.internalServerError().body(result);
}
}Common Email Patterns for SaaS
Stripe Webhook Handler
package com.example.controller;
import com.example.service.EmailService;
import com.example.service.EmailTemplateService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Map;
@RestController
@RequestMapping("/webhooks/stripe")
public class StripeWebhookController {
private static final Logger log = LoggerFactory.getLogger(StripeWebhookController.class);
private final EmailService emailService;
private final EmailTemplateService templateService;
private final String webhookSecret;
private final ObjectMapper objectMapper;
public StripeWebhookController(
EmailService emailService,
EmailTemplateService templateService,
@Value("${stripe.webhook-secret}") String webhookSecret,
ObjectMapper objectMapper) {
this.emailService = emailService;
this.templateService = templateService;
this.webhookSecret = webhookSecret;
this.objectMapper = objectMapper;
}
@PostMapping
public ResponseEntity<Map<String, Boolean>> handle(
@RequestBody String payload,
@RequestHeader("Stripe-Signature") String sigHeader) {
if (!verifySignature(payload, sigHeader)) {
return ResponseEntity.badRequest().build();
}
try {
JsonNode event = objectMapper.readTree(payload);
String eventType = event.get("type").asText();
JsonNode data = event.get("data").get("object");
switch (eventType) {
case "checkout.session.completed" -> handleCheckout(data);
case "invoice.payment_failed" -> handlePaymentFailed(data);
}
} catch (Exception e) {
log.error("Webhook processing error: {}", e.getMessage());
}
return ResponseEntity.ok(Map.of("received", true));
}
private void handleCheckout(JsonNode session) {
String email = session.get("customer_email").asText();
int amount = session.get("amount_total").asInt();
String formatted = "$%.2f".formatted(amount / 100.0);
String html = templateService.render("receipt", Map.of(
"amount", formatted, "plan", "Pro", "invoiceUrl", "#"
));
emailService.send(email, "Payment receipt - " + formatted, html);
log.info("Checkout completed for {}", email);
}
private void handlePaymentFailed(JsonNode invoice) {
String email = invoice.get("customer_email").asText();
emailService.send(email, "Payment failed",
"<h1>Payment issue</h1><p>We couldn't process your payment. Please update your card.</p>");
log.warn("Payment failed for {}", email);
}
private boolean verifySignature(String payload, String sigHeader) {
try {
Map<String, String> parts = new java.util.HashMap<>();
for (String part : sigHeader.split(",")) {
String[] kv = part.split("=", 2);
parts.put(kv[0], kv[1]);
}
String timestamp = parts.get("t");
String signature = parts.get("v1");
String signedPayload = timestamp + "." + payload;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(webhookSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] hash = mac.doFinal(signedPayload.getBytes(StandardCharsets.UTF_8));
String expected = bytesToHex(hash);
return MessageDigest.isEqual(expected.getBytes(), signature.getBytes());
} catch (Exception e) {
log.error("Signature verification failed: {}", e.getMessage());
return false;
}
}
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) sb.append("%02x".formatted(b));
return sb.toString();
}
}package com.example.controller;
import com.example.service.EmailService;
import com.example.service.EmailTemplateService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Map;
@RestController
@RequestMapping("/webhooks/stripe")
public class StripeWebhookController {
private static final Logger log = LoggerFactory.getLogger(StripeWebhookController.class);
private final EmailService emailService;
private final EmailTemplateService templateService;
private final String webhookSecret;
private final ObjectMapper objectMapper;
public StripeWebhookController(EmailService emailService, EmailTemplateService templateService,
@Value("${stripe.webhook-secret}") String webhookSecret, ObjectMapper objectMapper) {
this.emailService = emailService;
this.templateService = templateService;
this.webhookSecret = webhookSecret;
this.objectMapper = objectMapper;
}
@PostMapping
public ResponseEntity<Map<String, Boolean>> handle(
@RequestBody String payload,
@RequestHeader("Stripe-Signature") String sigHeader) {
if (!verifySignature(payload, sigHeader)) {
return ResponseEntity.badRequest().build();
}
try {
JsonNode event = objectMapper.readTree(payload);
String eventType = event.get("type").asText();
JsonNode data = event.get("data").get("object");
switch (eventType) {
case "checkout.session.completed" -> {
String email = data.get("customer_email").asText();
int amount = data.get("amount_total").asInt();
String formatted = "$%.2f".formatted(amount / 100.0);
String html = templateService.render("receipt", Map.of(
"amount", formatted, "plan", "Pro", "invoiceUrl", "#"));
emailService.send(email, "Payment receipt - " + formatted, html);
}
case "invoice.payment_failed" -> {
String email = data.get("customer_email").asText();
emailService.send(email, "Payment failed",
"<h1>Payment issue</h1><p>Please update your payment method.</p>");
}
}
} catch (Exception e) {
log.error("Webhook error: {}", e.getMessage());
}
return ResponseEntity.ok(Map.of("received", true));
}
private boolean verifySignature(String payload, String sigHeader) {
try {
Map<String, String> parts = new java.util.HashMap<>();
for (String part : sigHeader.split(",")) {
String[] kv = part.split("=", 2);
parts.put(kv[0], kv[1]);
}
String signedPayload = parts.get("t") + "." + payload;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(webhookSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = bytesToHex(mac.doFinal(signedPayload.getBytes(StandardCharsets.UTF_8)));
return MessageDigest.isEqual(expected.getBytes(), parts.get("v1").getBytes());
} catch (Exception e) { return false; }
}
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) sb.append("%02x".formatted(b));
return sb.toString();
}
}package com.example.controller;
import com.example.service.EmailService;
import com.example.service.EmailTemplateService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Map;
@RestController
@RequestMapping("/webhooks/stripe")
public class StripeWebhookController {
private static final Logger log = LoggerFactory.getLogger(StripeWebhookController.class);
private final EmailService emailService;
private final EmailTemplateService templateService;
private final String webhookSecret;
private final ObjectMapper objectMapper;
public StripeWebhookController(EmailService emailService, EmailTemplateService templateService,
@Value("${stripe.webhook-secret}") String webhookSecret, ObjectMapper objectMapper) {
this.emailService = emailService;
this.templateService = templateService;
this.webhookSecret = webhookSecret;
this.objectMapper = objectMapper;
}
@PostMapping
public ResponseEntity<Map<String, Boolean>> handle(
@RequestBody String payload,
@RequestHeader("Stripe-Signature") String sigHeader) {
if (!verifySignature(payload, sigHeader)) {
return ResponseEntity.badRequest().build();
}
try {
JsonNode event = objectMapper.readTree(payload);
String eventType = event.get("type").asText();
JsonNode data = event.get("data").get("object");
switch (eventType) {
case "checkout.session.completed" -> {
String email = data.get("customer_email").asText();
int amount = data.get("amount_total").asInt();
String formatted = "$%.2f".formatted(amount / 100.0);
String html = templateService.render("receipt", Map.of(
"amount", formatted, "plan", "Pro", "invoiceUrl", "#"));
emailService.send(email, "Payment receipt - " + formatted, html);
}
case "invoice.payment_failed" -> {
String email = data.get("customer_email").asText();
emailService.send(email, "Payment failed",
"<h1>Payment issue</h1><p>Please update your payment method.</p>");
}
}
} catch (Exception e) {
log.error("Webhook error: {}", e.getMessage());
}
return ResponseEntity.ok(Map.of("received", true));
}
private boolean verifySignature(String payload, String sigHeader) {
try {
Map<String, String> parts = new java.util.HashMap<>();
for (String part : sigHeader.split(",")) {
String[] kv = part.split("=", 2);
parts.put(kv[0], kv[1]);
}
String signedPayload = parts.get("t") + "." + payload;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(webhookSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = bytesToHex(mac.doFinal(signedPayload.getBytes(StandardCharsets.UTF_8)));
return MessageDigest.isEqual(expected.getBytes(), parts.get("v1").getBytes());
} catch (Exception e) { return false; }
}
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) sb.append("%02x".formatted(b));
return sb.toString();
}
}Async Email Sending
Don't block your request thread with email sends. Use @Async with a dedicated thread pool:
// src/main/java/com/example/config/AsyncConfig.java
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("emailExecutor")
public Executor emailExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("email-");
executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}// src/main/java/com/example/service/AsyncEmailService.java
package com.example.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class AsyncEmailService {
private static final Logger log = LoggerFactory.getLogger(AsyncEmailService.class);
private final EmailService emailService;
private final EmailTemplateService templateService;
public AsyncEmailService(EmailService emailService, EmailTemplateService templateService) {
this.emailService = emailService;
this.templateService = templateService;
}
@Async("emailExecutor")
public void sendWelcome(String email, String name) {
var html = templateService.render("welcome", java.util.Map.of(
"name", name, "dashboardUrl", "https://app.yoursite.com/dashboard"
));
emailService.send(email, "Welcome, " + name, html);
}
@Async("emailExecutor")
public void sendPasswordReset(String email, String resetUrl) {
var html = templateService.render("password-reset", java.util.Map.of("resetUrl", resetUrl));
emailService.send(email, "Reset your password", html);
}
}
// Usage in controller — returns instantly, email sends in background
@PostMapping("/register")
public ResponseEntity<Map<String, Object>> register(@RequestBody RegisterRequest request) {
var user = userService.create(request);
asyncEmailService.sendWelcome(user.email(), user.name());
return ResponseEntity.ok(Map.of("user", user));
}Error Handling with Spring Retry
Add automatic retries with exponential backoff:
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
</dependency>// Enable retry
@SpringBootApplication
@EnableRetry
public class Application { ... }
// Add retry to email service
@Service
public class SequenzyEmailService implements EmailService {
@Override
@Retryable(
retryFor = { WebClientResponseException.ServiceUnavailable.class,
WebClientResponseException.TooManyRequests.class },
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public EmailResult send(String to, String subject, String body) {
// ... existing implementation
}
@Recover
public EmailResult recover(WebClientResponseException e, String to, String subject, String body) {
log.error("Email permanently failed after retries to {}: {}", to, e.getMessage());
return EmailResult.fail("Failed after retries: " + e.getMessage());
}
}Testing
// src/test/java/com/example/service/EmailServiceTest.java
package com.example.service;
import com.example.config.EmailProperties;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.jupiter.api.*;
import static org.assertj.core.api.Assertions.assertThat;
class SequenzyEmailServiceTest {
private MockWebServer mockServer;
private SequenzyEmailService service;
@BeforeEach
void setUp() throws Exception {
mockServer = new MockWebServer();
mockServer.start();
var props = new EmailProperties(
"sequenzy",
"test-api-key",
"test@test.com",
"Test App"
);
// Override base URL to point to mock server
service = new SequenzyEmailService(props) {
// Test constructor that uses mock server URL
};
}
@AfterEach
void tearDown() throws Exception {
mockServer.shutdown();
}
@Test
void send_returnsSuccess_onOkResponse() {
mockServer.enqueue(new MockResponse()
.setResponseCode(200)
.setBody("{\"jobId\": \"job-123\"}")
.addHeader("Content-Type", "application/json"));
var result = service.send("user@test.com", "Test", "<p>Hello</p>");
assertThat(result.success()).isTrue();
assertThat(result.id()).isEqualTo("job-123");
}
@Test
void send_returnsError_onRateLimit() {
mockServer.enqueue(new MockResponse().setResponseCode(429));
var result = service.send("user@test.com", "Test", "<p>Hello</p>");
assertThat(result.success()).isFalse();
}
}Template Testing
@SpringBootTest
class EmailTemplateServiceTest {
@Autowired
private EmailTemplateService templateService;
@Test
void welcomeTemplate_containsNameAndDashboardLink() {
String html = templateService.render("welcome", Map.of(
"name", "Jane",
"dashboardUrl", "https://app.test.com/dashboard"
));
assertThat(html).contains("Welcome, Jane");
assertThat(html).contains("Go to Dashboard");
assertThat(html).contains("https://app.test.com/dashboard");
}
@Test
void receiptTemplate_formatsAmount() {
String html = templateService.render("receipt", Map.of(
"amount", "$49.99",
"plan", "Pro",
"invoiceUrl", "#"
));
assertThat(html).contains("$49.99");
assertThat(html).contains("Pro");
}
}Going to Production
1. Verify Your Domain
Add SPF, DKIM, and DMARC DNS records through your provider's dashboard. Without this, emails go straight to spam.
2. Use Spring Profiles
# application-prod.properties
email.api-key=${SEQUENZY_API_KEY}
stripe.webhook-secret=${STRIPE_WEBHOOK_SECRET}java -jar app.jar --spring.profiles.active=prod3. Use a Dedicated Sending Domain
Send from mail.yourapp.com instead of your root domain.
Production Checklist
Before going live, review the email deliverability guide to make sure your emails reach the inbox.
- [ ] Domain verified ([SPF, DKIM, DMARC](/blog/how-to-set-up-email-authentication-spf-dkim-dmarc))
- [ ] Dedicated sending domain (mail.yourapp.com)
- [ ] API keys in environment variables (not properties files)
- [ ] Spring Profiles for env-specific config
- [ ] @Async for non-blocking email sends
- [ ] Spring Retry for automatic retries
- [ ] Thread pool configured for email executor
- [ ] Structured logging (SLF4J) on all send attempts
- [ ] Thymeleaf templates with proper HTML escaping
- [ ] Stripe webhook signature verification
- [ ] Unit tests with MockWebServer
- [ ] Integration tests for templates
Beyond Transactional: Marketing Emails and Sequences
At some point, you'll want more than one-off transactional emails. You'll want to:
- Send onboarding sequences that guide new users through your product over several days
- Run marketing campaigns to announce features or share updates
- Automate lifecycle emails based on what users do (or don't do) in your app
- Track engagement to see which emails get opened and clicked
Most teams stitch together a transactional provider (Resend, SendGrid) with a separate marketing tool (Mailchimp, ConvertKit). That means two dashboards, two billing systems, and keeping subscriber lists in sync.
Sequenzy handles both from one platform. Same API, same dashboard. You get transactional sends, marketing campaigns, automated sequences, subscriber segments, and native Stripe integration for SaaS-specific automations like trial conversion and churn prevention.
// src/main/java/com/example/service/SequenzySubscriberService.java
@Service
public class SequenzySubscriberService {
private final WebClient webClient;
public SequenzySubscriberService(@Value("${email.api-key}") String apiKey) {
this.webClient = WebClient.builder()
.baseUrl("https://api.sequenzy.com/v1")
.defaultHeader("Authorization", "Bearer " + apiKey)
.build();
}
public void addSubscriber(String email, String firstName, String... tags) {
webClient.post().uri("/subscribers")
.bodyValue(Map.of(
"email", email,
"firstName", firstName,
"tags", tags
))
.retrieve().toBodilessEntity().block();
}
public void addTag(String email, String tag) {
webClient.post().uri("/subscribers/tags")
.bodyValue(Map.of("email", email, "tag", tag))
.retrieve().toBodilessEntity().block();
}
public void trackEvent(String email, String event, Map<String, Object> properties) {
webClient.post().uri("/subscribers/events")
.bodyValue(Map.of("email", email, "event", event, "properties", properties))
.retrieve().toBodilessEntity().block();
}
}FAQ
Should I use Spring Mail or API providers?
Use Spring Mail (spring-boot-starter-mail) only if you need to connect to a specific SMTP server (like an internal Exchange server). For everything else, use an API provider — they handle deliverability, bounce processing, TLS, and retries for you.
Why WebClient instead of RestTemplate?
RestTemplate is in maintenance mode. Spring recommends WebClient for new projects. It supports both synchronous (.block()) and reactive programming. If you don't want the WebFlux dependency, use RestClient (Spring Boot 3.2+) which is the new synchronous HTTP client.
How do I send emails from a scheduled task?
Use @Scheduled with your async email service:
@Scheduled(cron = "0 0 9 * * *") // Daily at 9 AM
public void sendDailyDigest() {
var users = userRepository.findUsersForDigest();
users.forEach(user -> asyncEmailService.sendDigest(user));
}How do I prevent sending real emails in tests?
Create a test implementation of EmailService:
@Profile("test")
@Service
public class FakeEmailService implements EmailService {
private final List<EmailRecord> sent = new ArrayList<>();
public EmailResult send(String to, String subject, String body) {
sent.add(new EmailRecord(to, subject, body));
return EmailResult.ok("fake-id");
}
public List<EmailRecord> getSentEmails() { return sent; }
record EmailRecord(String to, String subject, String body) {}
}How do I use Thymeleaf without Spring Web?
If your app doesn't have web endpoints (like a CLI or worker), configure Thymeleaf as a standalone template engine:
@Bean
public TemplateEngine templateEngine() {
var engine = new SpringTemplateEngine();
var resolver = new ClassLoaderTemplateResolver();
resolver.setPrefix("templates/");
resolver.setSuffix(".html");
resolver.setTemplateMode(TemplateMode.HTML);
engine.setTemplateResolver(resolver);
return engine;
}How do I handle rate limiting from the provider?
With Spring Retry, annotate your send method to retry on 429 responses. The @Backoff annotation handles exponential backoff. For more control, check the Retry-After header from the response and sleep accordingly.
Can I use records for email request/response DTOs?
Yes. Java 17+ records work great with Spring Boot. They're immutable, auto-generate equals/hashCode/toString, and work with Jackson for JSON serialization:
record EmailRequest(String to, String subject, String body) {}
record EmailResult(boolean success, String id, String error) {}Wrapping Up
Here's what we covered:
- API providers over Spring Mail for modern email delivery
- Spring DI with
EmailServiceinterface for testability - Thymeleaf templates with layouts for maintainable HTML emails
- @Async with dedicated thread pools for non-blocking sends
- Spring Retry for automatic retries with exponential backoff
- Stripe webhooks with HMAC signature verification
- Production setup: profiles, secrets, logging, testing
The code in this guide is production-ready. Copy the patterns that fit your app, swap in your provider of choice, and start shipping emails.
Frequently Asked Questions
Should I use Spring's JavaMailSender or a dedicated email API?
For production, use a dedicated email API via RestTemplate or WebClient. Spring's JavaMailSender uses SMTP which requires managing connections and offers no delivery tracking. Email APIs are simpler and provide better deliverability and analytics.
How do I send emails asynchronously in Spring Boot?
Add @Async to your email-sending method and enable it with @EnableAsync on your config class. For production, use Spring's TaskExecutor with a thread pool or a message queue (RabbitMQ, Kafka) for reliable background processing.
How do I use Thymeleaf for email templates in Spring Boot?
Create templates in src/main/resources/templates/emails/. Inject SpringTemplateEngine, create a Context with variables, and call templateEngine.process("emails/welcome", context). Thymeleaf generates HTML with proper inline styles for email clients.
How do I store email API keys in Spring Boot?
Use application.properties or application.yml for non-sensitive config and environment variables for secrets. In production, use Spring Cloud Vault, AWS Secrets Manager, or your platform's secrets management. Access values with @Value("${email.api-key}").
How do I test email sending in Spring Boot?
Use @MockBean to mock your email service in integration tests. For unit tests, mock with Mockito: verify(emailService).send(any(EmailRequest.class)). Spring Boot Test provides @SpringBootTest for full context testing with auto-configured test beans.
How do I handle email sending failures in Spring Boot?
Use @Retryable from Spring Retry for automatic retries with configurable backoff. Catch specific exceptions to handle rate limits differently from auth errors. Log failures with SLF4J and expose health metrics through Spring Actuator.
Can I use Spring WebFlux for reactive email sending?
Yes. Use WebClient (reactive HTTP client) to call your email API. Return Mono<Void> from your email service method. This is beneficial when sending multiple emails concurrently since WebFlux handles backpressure and non-blocking I/O efficiently.
How do I configure different email settings per Spring profile?
Create application-dev.yml, application-test.yml, and application-prod.yml with environment-specific email settings. Spring automatically loads the correct file based on the active profile. Use a console logger in dev and real API credentials in prod.
How do I add rate limiting to email endpoints in Spring Boot?
Use Bucket4j with Spring Boot starter for declarative rate limiting. Annotate your controller methods with @RateLimit or configure limits in application.yml. Use Redis as the backend for consistency across multiple instances.
How do I send emails with attachments in Spring Boot?
Use MimeMessageHelper for Spring Mail or pass base64-encoded file data to your email API. Read files with ResourceLoader or MultipartFile from uploaded content. Set the MIME type and filename explicitly for proper rendering in email clients.