Most servers send mail before they do much else: a welcome, a receipt, a link to set a new password. This chapter covers sending it. One class describes a message, one bean sends it, and the transport behind the bean is chosen by a property. The transport is SMTP for a provider or a relay, the Amazon SES API, or memory for development and tests.
The names follow Spring where a Spring developer would look for them, and
Spring Boot’s spring.mail.* properties are read. The surface is smaller.
Mail stands in for SimpleMailMessage, MimeMessage and
MimeMessageHelper, and MailSender for JavaMailSender. SMTP is implemented
on the backend’s own network layer with no mail library, and the build links
only the transport the configuration names. A server that never injects
MailSender contains no mail code at all.
Sending a mail
Two settings are enough:
cn1.mail.url=smtp://${SMTP_USER}:${SMTP_PASSWORD}@smtp.example.com:587
cn1.mail.from=Acme <[email protected]>
cn1.mail.from is the sender of every mail that doesn’t name its own. Inject
MailSender like any other bean:
@Component
public class Signup {
private final DataSource db;
private final MailSender mail;
public Signup(DataSource db, MailSender mail) {
this.db = db;
this.mail = mail;
}
@Transactional
public void register(String name, String email) throws IOException {
db.execute("INSERT INTO accounts (name, email) VALUES (?, ?)",
new Object[] {name, email});
// Staged in this transaction, sent once it commits.
mail.send(Mail.to(email)
.subject("Welcome, " + name)
.text("Your account is ready. Sign in at https://example.com/login")
.build());
}
}
Mail.to(…) starts a builder, and build() checks everything: an address
that isn’t one, a header value with a line break in it, or an attachment with
no name throws IllegalArgumentException there. A Mail that exists can be
sent, so a malformed address fails in the code that produced it and not at the
mail server an hour later. Text built from user input is safe in any field,
because a value that would inject a header is refused and never escaped into
something else.
send returns as soon as the mail is queued. Delivery happens on a thread of
the mail support’s own, for two reasons. An SMTP exchange is several round
trips, and a request shouldn’t wait for them. And a mail that fails the first
time has to be tried again after the request is long over.
Sending inside a transaction
When the server has a database, send writes the encoded mail to the table
cn1_mail_outbox in the caller’s transaction. In the example above, the
account row and the welcome mail commit together. If register throws, the
transaction rolls back and no mail goes out. If the server stops right after
the commit, the row is still there, and the next server to start sends it.
That’s the transactional outbox Backend caching and messaging uses for messages, and it gives the same guarantee: a committed mail is sent at least once. In the rare case where a server dies between sending the mail and removing the row, the mail goes out twice. It never goes out zero times.
Without a database the queue is in memory. Mail is retried the same way, and whatever is still queued when the process stops is lost.
Delivery and retries
A mail the server couldn’t take is tried again with a growing delay: 30 seconds, then four times longer each time, up to an hour, for six attempts in all. What happens next depends on why it failed:
A temporary failure, such as a
4xxreply, a timeout or a dropped connection, is retried.A permanent failure, a
5xxreply, isn’t. The server said it will never take this mail, and sending it again only harms the sender’s reputation.
A mail that’s given up on moves to cn1_mail_dead with the reason, and a line
in the log says so. Nothing sends from that table. It’s there for an operator
to read.
sendNow skips the queue. It delivers on the calling thread and throws
MailException when the server refuses, for the few cases where the caller has
to know before it answers:
public boolean sendCode(String email, String code) throws IOException {
try {
mail.sendNow(Mail.to(email)
.subject("Your code")
.text("Your code is " + code)
.build());
return true;
} catch (MailException refused) {
// isPermanent(): the server will never take this mail.
return false;
}
}
A mail sent with sendNow isn’t part of the caller’s transaction and isn’t
retried.
| Key | Default | What it sets |
|---|---|---|
| 6 | Attempts before a mail is given up on. |
| 30000 | Milliseconds before the second attempt. |
| 4 | What each later wait is multiplied by. |
| 3600000 | The longest wait. |
| 10000 | Milliseconds between looks for mails a stopped server left behind. |
Templates
A mail’s HTML can come from the same templates that render pages, described in
Compiled HTML views. Put the template under templates/mail:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<p>Hello <span th:text="${customer}">customer</span>,</p>
<p>Order <strong th:text="${orderId}">1</strong> is on its way.</p>
</body>
</html>
public void shipped(Order order, String email) throws IOException {
Model model = new Model()
.addAttribute("customer", order.customer)
.addAttribute("orderId", Long.valueOf(order.id));
mail.send(Mail.to(email)
.subject("Your order shipped")
.template("mail/shipped", model)
.build());
}
The template is compiled by the build like every other, so a property the model doesn’t have fails the build with the file and position, and nothing is parsed when a mail is sent.
Every HTML mail should carry a plain text version too. Mail clients that show
text need it, and spam filters score a mail without one. The text is derived
from the rendered HTML, with the tags removed and the address of each link kept
beside its words. Calling text(…) on the builder gives your own instead.
A mail is read outside your site. A template for one can’t use a relative link or a stylesheet, and has no request to read. Write absolute URLs and inline styles.
Attachments and inline images
public void invoice(Order order, String email, byte[] pdf, byte[] logo) throws IOException {
mail.send(Mail.to(email)
.cc("[email protected]")
.replyTo("[email protected]")
.subject("Invoice " + order.id)
.html("<img src=\"cid:logo\" alt=\"Acme\"><p>Your invoice is attached.</p>")
.inline("logo", "image/png", logo)
.attach("invoice-" + order.id + ".pdf", "application/pdf", pdf)
.header("X-Order", String.valueOf(order.id))
.build());
}
attach adds a file the reader can save. inline adds an image the HTML
shows in place, referenced as cid: followed by the content id. The builder
picks the structure a mail client expects from what the mail contains, so
there’s no multipart mode to choose.
Subjects, display names and file names can hold any Unicode text. Bodies are
always UTF-8. header sets a header of your own, such as List-Unsubscribe.
Providers
cn1.mail.url | Sends through |
|---|---|
| SMTP with STARTTLS. Every provider with an SMTP endpoint, and any relay. |
| SMTP over TLS from the first byte. |
| The Amazon SES API in that region. |
| Nothing. Each mail’s headers and text are written to the log. |
| Nothing. Mails are kept for a test to read. The default. |
Without cn1.mail.url, Spring Boot’s spring.mail.host, spring.mail.port,
spring.mail.username and spring.mail.password select SMTP.
SMTP
A username or password containing @, : or / has to be URL-encoded in the
URL, or set apart from it with cn1.mail.username and cn1.mail.password.
smtp:// upgrades the connection with STARTTLS before it says anything else,
and refuses a server that doesn’t offer it. A password is never sent over a
connection that isn’t encrypted, whatever the settings say. For a relay on the
same machine that has no TLS, cn1.mail.smtp.starttls=false sends in the
clear, without credentials.
Connections are kept open between mails and reused, and several mails handed to
send(Mail…) in one call go over one connection.
| Key | Default | What it sets |
|---|---|---|
|
| |
|
| The name the client gives for itself. |
| A PEM file of certificates to trust in place of the system’s. | |
| 5000 | Milliseconds a connection may take to open. |
| 60000 | Milliseconds a reply may take. |
| 2 | Idle connections kept for the next mail. |
| 30000 | Milliseconds an idle connection is kept. |
Amazon SES
ses:// followed by a region sends through the SES API: one signed HTTPS
request for each mail, with no SMTP credentials to issue. The credentials are
the machine’s own, read from the environment, a container role or an instance
role, as for the other AWS services in Backend caching and messaging. The sender has to
be an identity SES has verified. cn1.mail.ses.configuration-set names a
configuration set.
Testing
A test that names no cn1.mail.url sends nothing. Mails are kept in memory, and
MailSender hands them back:
@BackendTest(properties = "cn1.mail.from=Acme <[email protected]>")
class SignupTest {
@Autowired
private Signup signup;
@Autowired
private MailSender mail;
@BeforeEach
void emptyInbox() throws Exception {
mail.clearSent();
}
@Test
void aNewAccountIsWelcomed() throws Exception {
signup.register("Ada", "[email protected]");
assertEquals(1, mail.sent().size());
Mail welcome = mail.sent().get(0);
assertEquals("[email protected]", welcome.getTo().get(0));
assertEquals("Welcome, Ada", welcome.getSubject());
assertTrue(welcome.getText().contains("https://example.com/login"));
}
}
sent() returns the mails in the order they were sent, with templates already
rendered, so getHtml() and getText() are what a reader would have seen.
Mail is captured during the call to send, so there’s nothing to wait for.
During development cn1.mail.url=log: prints each mail. To look at mails in a
real client, run a catch-all SMTP server on your machine and point
cn1.mail.url at smtp://localhost:1025 with cn1.mail.smtp.starttls=false.
Observing mail
The counters cn1.mail.sent, cn1.mail.failed, cn1.mail.refused and
cn1.mail.retried, and the gauge cn1.mail.outbox.pending, are exported with
the metrics Backend tracing, metrics and management describes. Every hand-over to the
mail server is a span named mail send, with the transport, the number of
recipients and the size of the message. No address is ever written to a span, a
metric or the log.
Once a server has sent a mail, its health endpoint reports the transport under
mail. When the transport is down, that’s reported and the server stays in
service: mail waits in the outbox, and the server’s requests are no worse
served for it.
What isn’t supported
Receiving mail. There’s no IMAP or POP3 client.
Signing with DKIM. The provider or the relay signs.
S/MIME and PGP.
Character sets other than UTF-8.
Pitfalls
No
cn1.mail.urlin production. The default keeps mail in memory and sends nothing. The server says so in its log the first time a mail is sent.A sender the provider hasn’t verified. Providers refuse mail from a domain that isn’t yours to send from. That’s a permanent failure, so every mail lands in
cn1_mail_dead.Waiting on
sendNowin a request. It holds the request for the whole SMTP exchange. Usesendunless the answer depends on the outcome.Treating accepted as delivered. The server accepting a mail says nothing about the inbox. Bounces and complaints come back through the provider’s webhooks or its bounce address.
A handler that isn’t safe to repeat. A committed mail can go out twice after a crash. Put what makes a mail unique, such as an order number, where the reader sees it.