Order confirmations, password resets, receipts — most applications send email eventually. Spring Boot's mail support is small, and the interesting decisions are about failure and testing rather than about SMTP.
Setup
<!-- Adding this starter alone changes nothing at runtime: Boot's mail
autoconfiguration only builds a JavaMailSender when spring.mail.host is set,
which it is not by default. See MailService for how that is handled. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>That conditional behaviour is the switch. No spring.mail.host, no
JavaMailSender bean, nothing sent.
# NO spring.mail.host by default, and that is the switch: without it Boot builds no
# JavaMailSender at all and MailService quietly does nothing. To actually send, run a
# local sink and point at it - MailHog or Mailpit both work:
# docker run -p 1025:1025 -p 8025:8025 axllent/mailpit
# ./mvnw spring-boot:run -Dspring-boot.run.arguments=--spring.mail.host=localhost,--spring.mail.port=1025
# then read what was "sent" at http://localhost:8025. Never point a demo at a real
# SMTP relay - the first bad test address is a bounce on somebody's real domain.
pizza.mail.from=orders@pizza.testA local SMTP sink is the right development setup. Mailpit accepts everything, sends nothing onward, and gives you a web inbox to read. You get realistic behaviour with no risk of emailing a real person during a test run.
Only the envelope sender is bound as an application property:
/**
* Outgoing mail.
*
* <p>Only the envelope sender lives here — the SMTP host, port and credentials are Boot's own
* {@code spring.mail.*} properties, and duplicating them under {@code pizza.*} would just give
* the two copies a chance to disagree. Bind your own settings, not the framework's.
*/
public record Mail(@NotBlank String from) {}Optional by construction
/**
* <h2>Why {@link ObjectProvider} instead of injecting {@link JavaMailSender} directly</h2>
*
* <p>Boot only creates a {@code JavaMailSender} when {@code spring.mail.host} is set. Injecting it
* as a normal required dependency would therefore make the entire application fail to start on any
* machine without mail configured — which, for a demo somebody just cloned, is a terrible first
* experience.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MailServiceImpl implements MailService {
private final ObjectProvider<JavaMailSender> mailSenderProvider;
private final TemplateEngine templateEngine;
private final PizzaProperties properties;
@Override
public boolean isConfigured() {
return mailSenderProvider.getIfAvailable() != null;
}
}Lesson 6 covers the pattern. @Autowired(required = false) does the same job less
explicitly and does not work with constructor injection.
Sending HTML
@Override
public void sendOrderConfirmation(OrderDTO order) {
JavaMailSender sender = mailSenderProvider.getIfAvailable();
if (sender == null) {
log.debug("No mail server configured — skipping the confirmation for order {}", order.id());
return;
}
if (order.email() == null || order.email().isBlank()) {
log.warn("Order {} has no email address — nothing to send to", order.id());
return;
}
try {
Context context = new Context();
context.setVariable("order", order);
String html = templateEngine.process("receipt", context);
MimeMessage message = sender.createMimeMessage();
// multipart=true is required to attach anything; the charset must be stated or a
// customer named Zoë gets mojibake in the subject line.
MimeMessageHelper helper =
new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());
helper.setFrom(properties.mail().from());
helper.setTo(order.email());
helper.setSubject("Your Pizza order " + order.id());
// The second argument is what makes this HTML rather than a wall of angle brackets.
// A production sender would pass a plain-text alternative as the first argument, for
// clients that refuse HTML.
helper.setText(html, true);
sender.send(message);
log.info("Confirmation for order {} sent to {}", order.id(), order.email());
} catch (Exception ex) {
log.error("Could not send the confirmation for order {} — the order is unaffected",
order.id(), ex);
}
}Three details that each cause a bug if missed:
multipart = truein theMimeMessageHelperconstructor, or attachments and inline images are impossible.- The charset. Omit it and non-ASCII characters arrive as mojibake — always for someone else, never in your testing.
setText(html, true)— the boolean is what marks the body as HTML.falseshows the customer your markup.
For a simple message with no HTML, SimpleMailMessage is less ceremony:
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(properties.mail().from());
message.setTo(order.email());
message.setSubject("Your order is on its way");
message.setText("Order " + order.id() + " has left the kitchen.");
sender.send(message);The body is a Thymeleaf template
// The Context is the model. Thymeleaf has no idea it is producing an email; it sees the
// same variable name the receipt page sets, which is why one template serves both.
Context context = new Context();
context.setVariable("order", order);
String html = templateEngine.process("receipt", context);The same templates/receipt.html that lesson 15 renders as a web page. No HTTP request
is involved, so there is no view resolution — process() just returns the HTML.
One caveat that catches people building email templates: email clients are not
browsers. Many ignore <style> blocks, most ignore external stylesheets,
and support for flexbox and grid is poor. Serious HTML email uses tables and inline styles. The pizza
API's receipt uses a <style> block because it is primarily a web page; a
production email pipeline would run it through a CSS inliner first.
⚠️ Send it off the request thread, and never let it fail the operation
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendConfirmation(OrderPlacedEvent event) {
try {
mailService.sendOrderConfirmation(
orderService.getOrderByPublicId(event.orderPublicId()));
} catch (Exception ex) {
log.error("Confirmation for order {} failed — the order itself is unaffected",
event.orderPublicId(), ex);
}
}Three separate decisions, each from an earlier lesson:
AFTER_COMMIT(lesson 9) — never email a customer about an order that is about to roll back.@Async(lesson 28) — SMTP can take seconds, and the customer should not wait for it.- Catch everything — and the
MailServiceImplcatch block explains why it swallows too:
// Swallowed on purpose, and logged loudly. This is called from an AFTER_COMMIT
// listener: the order exists and is paid for. Letting a mail failure propagate would
// achieve nothing except noise, since there is no transaction left to roll back.A failed email must never fail the order. The order is committed and paid for; the email is a courtesy. Getting this backwards means a mail outage stops customers buying pizza.
Production is a different problem
Sending from your own server means fighting deliverability: SPF, DKIM and DMARC records, IP reputation, bounce and complaint handling. It is a specialist job, and doing it badly means your mail goes to spam.
Use a provider — SES, SendGrid, Postmark, Mailgun. Two ways to integrate:
# 1. As plain SMTP — no code changes at all
spring.mail.host=email-smtp.us-west-2.amazonaws.com
spring.mail.port=587
spring.mail.username=${SMTP_USERNAME}
spring.mail.password=${SMTP_PASSWORD}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=trueOr via their API/SDK, which gives you delivery events, bounce webhooks and templates at the cost of provider coupling. SMTP first is the reasonable default — it is portable, and the code above does not change.
Two operational habits worth building in early: put sending behind a queue (lesson 30) so a provider outage delays mail rather than losing it, and handle bounces so you stop mailing addresses that do not exist.
Testing
Assert against a sink or a mock, never a real inbox:
@SpringBootTest
class MailServiceTest {
@MockitoBean
private JavaMailSender mailSender;
@Autowired
private MailService mailService;
@Test
void sendsHtmlConfirmationToTheCustomer() {
given(mailSender.createMimeMessage())
.willReturn(new JavaMailSenderImpl().createMimeMessage());
mailService.sendOrderConfirmation(anOrder("customer@pizza.test"));
then(mailSender).should().send(any(MimeMessage.class));
}
}For a real end-to-end check, GreenMail starts an in-process SMTP server and lets you assert on what actually arrived — subject, recipients and body — which catches the charset and HTML-flag mistakes a mock cannot.
What to take from this
- No
spring.mail.host, no sender. UseObjectProviderso the app still starts. MimeMessageHelperwith multipart, charset andsetText(html, true).- Render the body from a template, shared with the web page.
AFTER_COMMIT+@Async+ catch everything. Email must never fail the order.- A local sink for development, a provider for production.
Next: testing — the slice you should reach for first.