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 |
|
|
Render emails without disturbing the web templates |
A separate |
|
Load shipped templates and, optionally, templates held in a string |
|
|
A plain-text body and an HTML body in one message |
TEXT and HTML templates, then |
|
A logo embedded in the HTML and a file attached |
|
Building the message: MimeMessageHelper, inline images and attachments |
The message in the reader’s language |
|
|
Let staff edit a template in an admin screen |
Not Thymeleaf expressions; a small placeholder language in your own code. |
|
A PDF of the same content |
Render HTML with the same engine and hand it to a PDF library. |
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.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
Source: Spring Boot — Sending Email
Credentials never go into the file: read them from the environment (or a secret store) through placeholders.
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
Source: Spring Boot — Sending Email
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
ClassLoaderTemplateResolverfor TEXT templates, withtext/*as resolvable pattern, prefix/mail/and suffix.txt; -
another for the HTML templates (
html/*, suffix.html); -
a
StringTemplateResolverlast 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.
@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;
}
}
Source: Javadoc: StringTemplateResolver
Four details in that listing were found by running it and are worth keeping.
-
The wrapper class is on purpose.
MailTemplateEngineis a four-line class that holds theSpringTemplateEngineand exposes oneprocessmethod. When the same engine was instead declared as a@Beanof typeSpringTemplateEngine, the test printed the single engine bean nameemailTemplateEngineand Boot’s owntemplateEnginewas gone: Boot creates its web engine only when no otherISpringTemplateEngineexists, 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 everyITemplateResolverbean 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 withsetTemplateEngineMessageSource(the article does the same). The mail bundle can still be a bean, as above. -
setFallbackToSystemLocale(false).ResourceBundleMessageSourcefalls back to the JVM’s default locale when there is no bundle for the requested one. In the first run an EnglishContextproduced Spanish text because the scratch JVM’s default locale was Spanish and there was noMailMessages_en. With the fallback off, the baseMailMessages.propertiesis the default for every locale without its own file. -
The
StringTemplateResolveris a catch-all. It has no pattern, so any name that does not start withtext/,csv/orhtml/is treated as template source. That is what makes stored templates possible and also why the name passed toprocessmust never come from outside the application (see Templates stored in a database). A mistyped classpath name such ashtml/loan-remnideris not affected: the HTML resolver claims it by pattern and the run produced aTemplateInputExceptionformail/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.
<!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.
[(#{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})]
Source: Using Thymeleaf — Textual syntax
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.
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:
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
<!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>
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.
# 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
Source: Using Thymeleaf — Messages
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:
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.
@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 amultipart/alternativebody: 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 aContent-ID. The template’scid:bookshelf-logoreference matches that id, which is why the id travels in theContext(logoCid) instead of being repeated in two places. The article loads the image from an upload through aByteArrayResource; aClassPathResource,FileSystemResource(no copy into memory) orUrlResourcework the same. -
addAttachment(name, resource, contentType)adds a downloadable file. The CSV text is itself a rendered template. -
The
setTextcall must come beforeaddInline, 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:
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
Source: Javadoc: MimeMessageHelper
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.
public class CapturingMailSender extends JavaMailSenderImpl {
public final List<MimeMessage> sent = new ArrayList<>();
@Override
public void send(MimeMessage... mimeMessages) {
sent.addAll(List.of(mimeMessages));
}
}
Source: Spring Framework — Email
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.
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);
Source: Javadoc: StringTemplateResolver
<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 |
|---|---|
|
Rendered (Ada, Dune). |
|
Rendered (DUNE). |
|
Rendered: object creation is allowed outside restricted contexts. |
|
Rendered (2026-10-12): any public method of any object in the model is reachable. |
|
Rejected: "Access is forbidden for type 'java.lang.System' in this expression context". |
|
Rejected only because this plain |
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:
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:
<p>Dear Ada <script>x()</script>, 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 |
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 |
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 |
|
|
Mail in the wrong language on servers whose default locale differs. Use |
A |
|
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 |
Relative image paths in the shipped HTML |
Fine for the prototype, but the mail client cannot load them. Use |
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 |