Spring Boot – Sending Email

August 10, 20266 min readUpdated 8/18/2026

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.test

A 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 = true in the MimeMessageHelper constructor, 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. false shows 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 MailServiceImpl catch 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=true

Or 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. Use ObjectProvider so the app still starts.
  • MimeMessageHelper with multipart, charset and setText(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.