Emails & Text Templates

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Thymeleaf 3.1.5.RELEASE, Spring Boot 4.1.1 (Spring Framework 7.0.9) and Java 21, checked on 2026-10-08, and every example was compiled and run in a scratch Spring Boot application (Maven wrapper, a test that renders the templates and a mail sender that records the message instead of connecting to an SMTP server, JDK 26 with --release 21); the output shown is the real output. Email rendering is independent of the web layer: the engine described here is a plain SpringTemplateEngine driven with a Context, so it works in a Spring MVC application, in a Spring WebFlux application and in a batch or scheduled job with no web layer at all. The scenario is the Bookshelf loan reminder: a reader borrowed a book, the due date is near (or has passed), and the application sends an HTML email with a plain-text alternative, a logo embedded in the message and a CSV attachment.

Thymeleaf’s job here is only to turn a template and some data into a String. Building the MIME message, connecting to the server and sending belong to Spring’s JavaMailSender and MimeMessageHelper, and the page keeps the two concerns apart.

In a hurry: what to use for what

I need to…​ Use Section

Add email support to a Boot application

spring-boot-starter-mail and spring.mail.host (the JavaMailSender bean exists only when a host or a JNDI name is set).

The mail side: starter, properties and the sender

Render emails without disturbing the web templates

A separate SpringTemplateEngine that is not declared as a bean of that type.

A dedicated email template engine

Load shipped templates and, optionally, templates held in a string

ClassLoaderTemplateResolver (one per mode) plus a StringTemplateResolver last in the chain.

A dedicated email template engine

A plain-text body and an HTML body in one message

TEXT and HTML templates, then helper.setText(plain, html).

The HTML, TEXT and CSV templates

A logo embedded in the HTML and a file attached

helper.addInline(...) with a cid: source and helper.addAttachment(...), with a multipart mode that allows both.

Building the message: MimeMessageHelper, inline images and attachments

The message in the reader’s language

new Context(locale) and a message source set on the email engine.

Internationalisation in emails

Let staff edit a template in an admin screen

Not Thymeleaf expressions; a small placeholder language in your own code.

Templates stored in a database

A PDF of the same content

Render HTML with the same engine and hand it to a PDF library.

From email to PDF

The mail side: starter, properties and the sender

Spring Boot’s mail support lives in the spring-boot-starter-mail starter (managed by the Boot BOM, so no version is written). It brings in Spring’s mail abstraction and Jakarta Mail. The auto-configured JavaMailSender is created only when spring.mail.host (or spring.mail.jndi-name) is set; in the scratch application every other test failed with No qualifying bean of type JavaMailSender until a host was configured. The defaults reported by Boot 4.1.1 are spring.mail.protocol=smtp, spring.mail.default-encoding=UTF-8, spring.mail.ssl.enabled=false, spring.mail.ssl.verify-hostname=true and spring.mail.test-connection=false; spring.mail.properties.* passes keys straight to Jakarta Mail.

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

Credentials never go into the file: read them from the environment (or a secret store) through placeholders.

application.properties
spring.mail.host=${SMTP_HOST:localhost}
spring.mail.port=${SMTP_PORT:2525}
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

The official "Sending email in Spring with Thymeleaf" article configures JavaMailSenderImpl by hand in a @Configuration class; with Boot the properties above replace that class, and the rest of the article (engine, templates, MimeMessageHelper) still applies unchanged.

A dedicated email template engine

Email templates differ from page templates: they are found on the classpath under their own folder, they are rendered from a service rather than by a view resolver, they have their own message bundle, and (here) some of them come from a string. The article’s answer, still valid on 3.1, is a SpringTemplateEngine built just for email, with one template resolver per kind of template:

  • a ClassLoaderTemplateResolver for TEXT templates, with text/* as resolvable pattern, prefix /mail/ and suffix .txt;

  • another for the HTML templates (html/*, suffix .html);

  • a StringTemplateResolver last in the chain, with no resolvable pattern, which treats whatever the other resolvers did not claim as the template source itself.

Resolvers are asked in order; each one answers only when the template name matches its resolvable patterns. The scratch application adds a second TEXT resolver for csv/* so that the CSV attachment is also a template.

The email engine and its resolvers
@Configuration(proxyBeanMethods = false)
public class MailTemplateConfig {

    static final String ENCODING = "UTF-8";

    @Bean
    MessageSource mailMessageSource() {
        var source = new ResourceBundleMessageSource();
        source.setBasename("mail/MailMessages");
        source.setDefaultEncoding(ENCODING);
        source.setFallbackToSystemLocale(false);
        return source;
    }

    @Bean
    MailTemplateEngine mailTemplateEngine(MessageSource mailMessageSource) {
        var engine = new SpringTemplateEngine();
        engine.addTemplateResolver(classpathResolver(1, "text/*", ".txt", TemplateMode.TEXT));
        engine.addTemplateResolver(classpathResolver(2, "csv/*", ".csv", TemplateMode.TEXT));
        engine.addTemplateResolver(classpathResolver(3, "html/*", ".html", TemplateMode.HTML));
        engine.addTemplateResolver(stringResolver());
        engine.setTemplateEngineMessageSource(mailMessageSource);
        return new MailTemplateEngine(engine);
    }

    private static ITemplateResolver classpathResolver(
            int order, String pattern, String suffix, TemplateMode mode) {
        var resolver = new ClassLoaderTemplateResolver();
        resolver.setOrder(order);
        resolver.setResolvablePatterns(Set.of(pattern));
        resolver.setPrefix("/mail/");
        resolver.setSuffix(suffix);
        resolver.setTemplateMode(mode);
        resolver.setCharacterEncoding(ENCODING);
        resolver.setCacheable(true);
        return resolver;
    }

    private static ITemplateResolver stringResolver() {
        var resolver = new StringTemplateResolver();
        resolver.setOrder(4);
        resolver.setTemplateMode(TemplateMode.HTML);
        resolver.setCacheable(false);
        return resolver;
    }
}

Four details in that listing were found by running it and are worth keeping.

  • The wrapper class is on purpose. MailTemplateEngine is a four-line class that holds the SpringTemplateEngine and exposes one process method. When the same engine was instead declared as a @Bean of type SpringTemplateEngine, the test printed the single engine bean name emailTemplateEngine and Boot’s own templateEngine was gone: Boot creates its web engine only when no other ISpringTemplateEngine exists, so the web view resolver would be handed the email engine, which has none of the web resolvers. With the wrapper, the context holds exactly one engine bean, templateEngine. The resolvers are not beans either, for the same reason: Boot’s engine collects every ITemplateResolver bean and would start trying the email resolvers on web view names.

  • The message source must be set by hand. An engine that is not a bean is not given the application’s MessageSource, so the email bundle is attached with setTemplateEngineMessageSource (the article does the same). The mail bundle can still be a bean, as above.

  • setFallbackToSystemLocale(false). ResourceBundleMessageSource falls back to the JVM’s default locale when there is no bundle for the requested one. In the first run an English Context produced Spanish text because the scratch JVM’s default locale was Spanish and there was no MailMessages_en. With the fallback off, the base MailMessages.properties is the default for every locale without its own file.

  • The StringTemplateResolver is a catch-all. It has no pattern, so any name that does not start with text/, csv/ or html/ is treated as template source. That is what makes stored templates possible and also why the name passed to process must never come from outside the application (see Templates stored in a database). A mistyped classpath name such as html/loan-remnider is not affected: the HTML resolver claims it by pattern and the run produced a TemplateInputException for mail/html/loan-remnider.html, not a string render.

The template resolvers are cached here (setCacheable(true)) because shipped templates do not change at runtime; the string resolver is not, since the template text changes with the stored value. The Boot property spring.thymeleaf.cache configures only Boot’s own web resolver, not these.

The HTML, TEXT and CSV templates

Everything in the Standard Dialect works in an email template: Spring EL, th:if, th:each, utility objects, #{...} messages, th:remove. Because the engine is not driven by a web request, there is no request, session or @bean access; the data comes only from the Context, which also fixes the locale.

mail/html/loan-reminder.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <title th:remove="all">Loan reminder (prototype)</title>
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
  <img src="../images/bookshelf-logo.png" th:src="|cid:${logoCid}|" alt="Bookshelf" width="32" height="32"/>
  <p th:text="#{reminder.greeting(${loan.reader})}">Hello Ada,</p>
  <p th:text="#{reminder.body}">The book below is due back soon.</p>
  <table>
    <tr><td th:text="${loan.title}">Dune</td></tr>
    <tr>
      <th th:text="#{reminder.due}">Due date</th>
      <td th:text="${#temporals.format(loan.dueDate, 'd MMMM yyyy')}">1 January 2027</td>
    </tr>
  </table>
  <p th:if="${loan.daysOverdue() > 0}" th:text="#{reminder.late(${loan.daysOverdue()})}">Overdue</p>
  <p th:text="#{reminder.signature}">The Bookshelf team</p>
</body>
</html>

The template is natural: opened in a browser it shows the prototype (a relative image path, the sample greeting, a sample date), which is the article’s point about designing emails without sending them to see the result. The th:remove="all" on title drops the prototype-only title from the output, and th:src swaps the relative file for a cid: reference at render time.

The plain-text alternative is a TEXT-mode template. In TEXT mode the output expression is [(...)] (unescaped, right for plain text) and control flow uses the bracket element syntax; the page on Textual Template Modes explains it in full.

mail/text/loan-reminder.txt
[(#{reminder.greeting(${loan.reader})})]

[(#{reminder.body})]

  [(${loan.title})]
  [(#{reminder.due})]: [(${#temporals.format(loan.dueDate, 'd MMMM yyyy')})]
[# th:if="${loan.daysOverdue() > 0}"]
[(#{reminder.late(${loan.daysOverdue()})})]
[/]
--
[(#{reminder.signature})]

The same mechanism renders a CSV attachment. Quotes inside a field are doubled so that a title with a quote does not break the file.

mail/csv/loan-summary.csv
reader,title,due
[# th:each="l : ${loans}"]"[(${#strings.replace(l.reader,'"','""')})]","[(${#strings.replace(l.title,'"','""')})]",[(${l.dueDate})]
[/]

Loan is a record with reader, email, title and dueDate and a daysOverdue() method. The test renders the templates for a loan that is three days overdue (due 2026-10-05, run on 2026-10-08) with an English Context:

Rendered text part (English)
Hello Ada,

The book below is due back soon.

  Dune
  Due date: 5 October 2026

This loan is already 3 day(s) overdue.

--
The Bookshelf team
Rendered HTML part (English)
<!DOCTYPE html>
<html>
<head>

  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
  <img src="cid:bookshelf-logo" alt="Bookshelf" width="32" height="32"/>
  <p>Hello Ada,</p>
  <p>The book below is due back soon.</p>
  <table>
    <tr><td>Dune</td></tr>
    <tr>
      <th>Due date</th>
      <td>5 October 2026</td>
    </tr>
  </table>
  <p>This loan is already 3 day(s) overdue.</p>
  <p>The Bookshelf team</p>
</body>
</html>
Rendered CSV with a title containing quotes
reader,title,due
"Ada","Dune",2026-10-05
"Bo ""B""","Emma",2027-01-05

Two practical points about HTML email that Thymeleaf does not change: mail clients support far less CSS than browsers (inline styles and table layouts are the safe baseline), and the cid: image must exist in the message, which is what the next section does.

Internationalisation in emails

A web page learns the locale from the request; an email has no request, so the locale is whatever you put in the Context: new Context(locale). Typically it comes from the recipient’s saved preference. The #{...} expressions are then resolved against the message source set on the email engine, the same way the page on Internationalization describes for web pages.

mail/MailMessages.properties and mail/MailMessages_es.properties
# MailMessages.properties
reminder.subject=Bookshelf: "{0}" is due on {1}
reminder.greeting=Hello {0},
reminder.body=The book below is due back soon.
reminder.due=Due date
reminder.late=This loan is already {0} day(s) overdue.
reminder.signature=The Bookshelf team

# MailMessages_es.properties
reminder.subject=Bookshelf: "{0}" vence el {1}
reminder.greeting=Hola {0},
reminder.body=El libro siguiente debe devolverse pronto.
reminder.due=Fecha de devolución
reminder.late=Este préstamo lleva {0} día(s) de retraso.
reminder.signature=El equipo de Bookshelf

The message arguments use java.text.MessageFormat, so {0} and {1} are positional and a literal apostrophe in a message must be doubled. The subject is a message too, resolved in Java with the same MessageSource (see the service below). With new Context(Locale.forLanguageTag("es")) the same text template produces:

Rendered text part (Spanish)
Hola Ada,

El libro siguiente debe devolverse pronto.

  Dune
  Fecha de devolución: 5 octubre 2026

Este préstamo lleva 3 día(s) de retraso.

--
El equipo de Bookshelf

Dates and numbers follow the Context locale too (#temporals.format above used the pattern as given and the locale for the month name). Dates passed as message arguments are formatted by MessageFormat, not by Thymeleaf; the first run printed the subject as vence el 2026-10-05 because a LocalDate argument falls back to toString(), so the service formats the date with a locale-aware DateTimeFormatter before passing it.

Building the message: MimeMessageHelper, inline images and attachments

MimeMessageHelper is Spring’s convenience for building a MimeMessage. The multipart mode matters: a message with an alternative text part, an inline image and an attachment needs MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED. Within that constraint the order is: set the text first, then add inline resources, then attachments.

Rendering and sending the reminder
@Service
public class LoanReminderService {

    private static final String LOGO_CID = "bookshelf-logo";

    private final JavaMailSender mailSender;
    private final MailTemplateEngine templates;
    private final MessageSource messages;

    public LoanReminderService(JavaMailSender mailSender, MailTemplateEngine templates,
            MessageSource mailMessageSource) {
        this.mailSender = mailSender;
        this.templates = templates;
        this.messages = mailMessageSource;
    }

    public void sendReminder(Loan loan, List<Loan> allLoans, Locale locale) throws MessagingException {
        var ctx = new Context(locale);
        ctx.setVariable("loan", loan);
        ctx.setVariable("logoCid", LOGO_CID);

        String text = templates.process("text/loan-reminder", ctx);
        String html = templates.process("html/loan-reminder", ctx);

        var summaryCtx = new Context(locale);
        summaryCtx.setVariable("loans", allLoans);
        String csv = templates.process("csv/loan-summary", summaryCtx);

        MimeMessage message = mailSender.createMimeMessage();
        var helper = new MimeMessageHelper(message, MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED,
                StandardCharsets.UTF_8.name());
        helper.setFrom("library@example.com");
        helper.setTo(loan.email());
        helper.setSubject(messages.getMessage("reminder.subject",
                new Object[] {loan.title(), DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG)
                        .withLocale(locale).format(loan.dueDate())}, locale));
        helper.setText(text, html);
        helper.addInline(LOGO_CID, new ClassPathResource("mail/images/bookshelf-logo.png"), "image/png");
        helper.addAttachment("loans.csv", new ByteArrayResource(csv.getBytes(StandardCharsets.UTF_8)),
                "text/csv");
        mailSender.send(message);
    }
}
  • helper.setText(plain, html) produces a multipart/alternative body: mail clients that cannot, or are told not to, show HTML use the plain part.

  • addInline(contentId, resource, contentType) adds the image as a related part with a Content-ID. The template’s cid:bookshelf-logo reference matches that id, which is why the id travels in the Context (logoCid) instead of being repeated in two places. The article loads the image from an upload through a ByteArrayResource; a ClassPathResource, FileSystemResource (no copy into memory) or UrlResource work the same.

  • addAttachment(name, resource, contentType) adds a downloadable file. The CSV text is itself a rendered template.

  • The setText call must come before addInline, because the inline part is attached to the text body’s related container.

The recorded message, printed by the test with the body parts shortened, has this structure:

Structure of the recorded MIME message
Subject: Bookshelf: "Dune" vence el 5 de octubre de 2026
Content-Type: multipart/mixed
  Content-Type: multipart/related
    Content-Type: multipart/alternative
      Content-Type: text/plain; charset=UTF-8   (the TEXT template)
      Content-Type: text/html;charset=UTF-8     (the HTML template)
    Content-Type: image/png
    Content-Disposition: inline
    Content-ID: <bookshelf-logo>
  Content-Type: text/csv
  Content-Disposition: attachment; filename=loans.csv

The reminder itself is usually triggered by a scheduled job that selects the loans due soon; scheduling, and running it on one node only in a cluster, are covered on Scheduling & ShedLock.

Testing without an SMTP server

The test used for this page subclasses JavaMailSenderImpl, overrides send(MimeMessage...) to keep the message in a list and is registered as a @TestConfiguration bean, so nothing leaves the machine. The assertions (or the printed writeTo output) inspect subject, recipients, parts and rendered bodies. When the SMTP conversation itself matters, an in-process SMTP server such as GreenMail (com.icegreen:greenmail, version 2.1.14 at the time of writing) receives the real message over a local port.

A recording sender for tests
public class CapturingMailSender extends JavaMailSenderImpl {

    public final List<MimeMessage> sent = new ArrayList<>();

    @Override
    public void send(MimeMessage... mimeMessages) {
        sent.addAll(List.of(mimeMessages));
    }
}

Templates stored in a database

Sometimes the people who write the wording are not developers: support wants to change the reminder text without a release. The article’s fifth example is exactly this ("HTML text edited by the user"): the template reaches the engine as a String, which is what the StringTemplateResolver is for. The resolver treats the name passed to process as the template source, so a stored template is rendered by loading the text and handing it over.

Rendering a template held in a string (the stored text would come from a repository)
String stored = "<p th:text=\"|Dear ${#strings.toUpperCase(loan.reader)}|\">x</p>"
        + "<p>[[${new java.util.Date()}]] [[${loan.email}]]</p>";
var ctx = new Context(Locale.ENGLISH);
ctx.setVariable("loan", loan);
String html = templates.process(stored, ctx);
Output of the stored template
<p>Dear ADA</p><p>Thu Oct 08 01:58:08 CEST 2026 ada@example.com</p>

That output is the problem in one line: whoever edits the stored text is writing Thymeleaf expressions, and those run with the privileges of the application. In the scratch run a stored text called a utility method, constructed a java.util.Date and read the recipient’s email address from the model; nothing in the template mechanism distinguishes "wording" from "code".

Payload placed in a stored template (HTML mode) Result on 3.1.5

[[${loan.reader}]] and [[${loan.title}]]

Rendered (Ada, Dune).

[[${#strings.toUpperCase(loan.title)}]]

Rendered (DUNE).

[[${new java.util.Date()}]]

Rendered: object creation is allowed outside restricted contexts.

[[${loan.dueDate.plusDays(7)}]]

Rendered (2026-10-12): any public method of any object in the model is reachable.

[[${T(java.lang.System).getenv('HOME')}]]

Rejected: "Access is forbidden for type 'java.lang.System' in this expression context".

[[${@environment.getProperty('spring.application.name')}]]

Rejected only because this plain Context has no bean resolver: "No bean resolver registered in the context".

Read the last two rows as the engine’s general class restrictions doing their job and a plain Context holding back bean access by accident, not as a sandbox. Restricted expression mode applies only to specific contexts (preprocessing, th:utext, fragment names and a few more); an ordinary [[...]] in an HTML or TEXT body is not one of them, so expressions there are limited by the general restrictions alone. Anything you add to the Context later (a reader entity with a phone number, a service object, a WebContext or an evaluation context that exposes beans) becomes readable by every editor of every stored template, and the restrictions have been tightened more than once after bypasses were reported; the CVEs, affected and fixed versions are tabulated in Template Security & XSS, which also explains why expression restrictions are defence in depth rather than a boundary. Run at least Thymeleaf 3.1.5, but do not rely on the version to make editable expressions safe.

The advice from that page applies directly to email: do not give editors Thymeleaf. Give them a language that can do nothing but substitute values, and keep Thymeleaf for the templates that ship with the application and are reviewed like code. A minimal version, with {{name}} placeholders replaced by HTML-escaped values from a whitelist:

A placeholder-only merge for editor-supplied text
public final class PlaceholderTemplate {

    private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\{\\s*([a-zA-Z]+)\\s*}}");

    private PlaceholderTemplate() {
    }

    public static String render(String source, Map<String, String> values) {
        Matcher m = PLACEHOLDER.matcher(source);
        StringBuilder out = new StringBuilder();
        while (m.find()) {
            String value = values.get(m.group(1));
            if (value == null) {
                throw new IllegalArgumentException("Unknown placeholder: " + m.group(1));
            }
            m.appendReplacement(out, Matcher.quoteReplacement(HtmlUtils.htmlEscape(value)));
        }
        m.appendTail(out);
        return out.toString();
    }
}

Run with a hostile reader name and a stored text that tries an expression, the output is inert:

Output with hostile input
<p>Dear Ada &lt;script&gt;x()&lt;/script&gt;, please return Dune by 5 October 2026.</p>
<p>Ada [[${new java.util.Date()}]]</p>
Unknown placeholder: password

The three lines are the results of rendering a normal text, a text containing a Thymeleaf expression (printed literally, never evaluated) and a text asking for a placeholder that is not on the whitelist (rejected with an exception). The merged result can be wrapped in a fixed, shipped Thymeleaf layout template as a model value rendered with th:utext, because it has already been escaped by the merge; keep that wrapper under version control. If the stored content really must contain full Thymeleaf, restrict who can edit it to people you would trust with a deployment, treat the editor as a code-change workflow with review, and render it in an engine whose Context holds only the values the template needs.

From email to PDF

The same engine can render the body of a document that is not sent by email. A common requirement in the loan scenario is a printable receipt or an invoice: render an HTML template with process, exactly as above, and give the resulting string to an HTML-to-PDF step (the PDF libraries are compared in Introduction and Approaches, and the HTML-to-PDF converter of iText is described in HTML to PDF with pdfHTML). Thymeleaf only produces the markup; what the converter accepts (well-formed XHTML, supported CSS, fonts, page breaks) depends on the library, so check its requirements before writing the template, and keep a separate template folder (for example /pdf/, with its own resolver and order) for that output. This page deliberately goes no further than the hand-off.

Pitfalls

Mistake What happens and what to do

The email engine declared as a SpringTemplateEngine bean

Boot’s web engine backs off; the web view resolver gets the email engine. Wrap the engine or give it to the service directly.

Email resolvers declared as ITemplateResolver beans

Boot’s engine picks them up and tries them for web views. Keep them inside the email configuration.

No message source on the email engine

#{...} expressions do not see the application’s bundles. Call setTemplateEngineMessageSource.

ResourceBundleMessageSource left with the system-locale fallback

Mail in the wrong language on servers whose default locale differs. Use setFallbackToSystemLocale(false).

A LocalDate passed as a message argument

MessageFormat prints ISO text. Format dates before passing them or in the template.

Setting the text after the inline image, or using a mode without related parts

Spring requires the text to be set before inline resources, and the inline part needs a related-capable multipart mode. Use MULTIPART_MODE_MIXED_RELATED and set the text first.

Relative image paths in the shipped HTML

Fine for the prototype, but the mail client cannot load them. Use th:src with a cid: or an absolute https URL.

Template names built from user input

Anything outside the resolvable patterns is rendered as a template string. Choose from a fixed set of names.

User-edited text rendered with Thymeleaf

Template injection. Use a placeholder language and escape the values.

SMTP credentials written in properties or templates

Read them from the environment or a secret store; the examples use ${SMTP_PASSWORD:}.