diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/mail/Receipts.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/mail/Receipts.java
new file mode 100644
index 00000000000..1bfc3337f53
--- /dev/null
+++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/mail/Receipts.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codenameone.developerguide.backend.mail;
+
+import com.codename1.backend.annotations.Component;
+import com.codename1.backend.mail.Mail;
+import com.codename1.backend.mail.MailException;
+import com.codename1.backend.mail.MailSender;
+import com.codename1.backend.mvc.Model;
+import com.codenameone.developerguide.backend.orders.Order;
+
+import java.io.IOException;
+
+@Component
+public class Receipts {
+ private final MailSender mail;
+
+ public Receipts(MailSender mail) {
+ this.mail = mail;
+ }
+
+ // tag::backend-mail-template[]
+ 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());
+ }
+ // end::backend-mail-template[]
+
+ // tag::backend-mail-attach[]
+ public void invoice(Order order, String email, byte[] pdf, byte[] logo) throws IOException {
+ mail.send(Mail.to(email)
+ .cc("accounts@example.com")
+ .replyTo("billing@example.com")
+ .subject("Invoice " + order.id)
+ .html("
Your invoice is attached.
")
+ .inline("logo", "image/png", logo)
+ .attach("invoice-" + order.id + ".pdf", "application/pdf", pdf)
+ .header("X-Order", String.valueOf(order.id))
+ .build());
+ }
+ // end::backend-mail-attach[]
+
+ // tag::backend-mail-now[]
+ 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;
+ }
+ }
+ // end::backend-mail-now[]
+}
diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/mail/Signup.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/mail/Signup.java
new file mode 100644
index 00000000000..4eb308b4240
--- /dev/null
+++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/mail/Signup.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codenameone.developerguide.backend.mail;
+
+import com.codename1.backend.DataSource;
+import com.codename1.backend.annotations.Component;
+import com.codename1.backend.annotations.Transactional;
+import com.codename1.backend.mail.Mail;
+import com.codename1.backend.mail.MailSender;
+
+import java.io.IOException;
+
+// tag::backend-mail-send[]
+@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());
+ }
+}
+// end::backend-mail-send[]
diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/mail/SignupTest.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/mail/SignupTest.java
new file mode 100644
index 00000000000..e28cd679ed3
--- /dev/null
+++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/mail/SignupTest.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codenameone.developerguide.backend.mail;
+
+import com.codename1.backend.annotations.Autowired;
+import com.codename1.backend.mail.Mail;
+import com.codename1.backend.mail.MailSender;
+import com.codename1.backend.test.BackendTest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+// tag::backend-mail-test[]
+@BackendTest(properties = "cn1.mail.from=Acme ")
+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", "ada@example.com");
+
+ assertEquals(1, mail.sent().size());
+ Mail welcome = mail.sent().get(0);
+ assertEquals("ada@example.com", welcome.getTo().get(0));
+ assertEquals("Welcome, Ada", welcome.getSubject());
+ assertTrue(welcome.getText().contains("https://example.com/login"));
+ }
+}
+// end::backend-mail-test[]
diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/security/MailSecurityConfig.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/security/MailSecurityConfig.java
new file mode 100644
index 00000000000..f205e2f72a6
--- /dev/null
+++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/security/MailSecurityConfig.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codenameone.developerguide.backend.security;
+
+import com.codename1.backend.DataSource;
+import com.codename1.backend.annotations.Bean;
+import com.codename1.backend.annotations.Configuration;
+import com.codename1.backend.mail.MailSender;
+import com.codename1.backend.security.Customizer;
+import com.codename1.backend.security.HttpSecurity;
+import com.codename1.backend.security.SecurityFilterChain;
+import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager;
+import com.codename1.backend.security.crypto.PasswordEncoder;
+import com.codename1.backend.security.mail.EmailVerificationService;
+import com.codename1.backend.security.mail.JdbcOneTimeTokenRepository;
+import com.codename1.backend.security.mail.JdbcVerifiedEmailRepository;
+import com.codename1.backend.security.mail.OneTimeTokenService;
+import com.codename1.backend.security.mail.PasswordResetService;
+import com.codename1.backend.security.mfa.EmailCodeService;
+import com.codename1.backend.security.mfa.JdbcEmailCodeRepository;
+
+import java.io.IOException;
+
+/** The Backend security chapter's examples of the flows that send mail. */
+@Configuration
+public class MailSecurityConfig {
+// tag::backend-security-reset[]
+ @Bean
+ OneTimeTokenService oneTimeTokens(DataSource dataSource) {
+ return new OneTimeTokenService(new JdbcOneTimeTokenRepository(dataSource));
+ }
+
+ @Bean
+ PasswordResetService passwordReset(OneTimeTokenService tokens, MailSender mail,
+ JdbcUserDetailsManager users, PasswordEncoder encoder) {
+ return new PasswordResetService(tokens, mail, users, users, encoder);
+ }
+
+ @Bean
+ SecurityFilterChain web(HttpSecurity http) {
+ http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
+ .formLogin(Customizer.withDefaults())
+ .passwordReset(Customizer.withDefaults());
+ return http.build();
+ }
+// end::backend-security-reset[]
+
+// tag::backend-security-verify[]
+ @Bean
+ EmailVerificationService emailVerification(OneTimeTokenService tokens, MailSender mail,
+ DataSource dataSource) {
+ return new EmailVerificationService(tokens, mail,
+ new JdbcVerifiedEmailRepository(dataSource));
+ }
+
+ @Bean
+ SecurityFilterChain shop(HttpSecurity http, EmailVerificationService verification) {
+ http.authorizeHttpRequests(auth -> auth
+ .requestMatchers("/checkout/**").access(verification.verified())
+ .anyRequest().authenticated())
+ .formLogin(Customizer.withDefaults())
+ .emailVerification(Customizer.withDefaults());
+ return http.build();
+ }
+// end::backend-security-verify[]
+
+// tag::backend-security-verify-send[]
+ void afterSignup(EmailVerificationService verification, String username, String email)
+ throws IOException {
+ verification.send(username, email, "https://example.com/verify-email");
+ }
+// end::backend-security-verify-send[]
+
+// tag::backend-security-email-code[]
+ @Bean
+ EmailCodeService emailCodes(DataSource dataSource, MailSender mail) {
+ return new EmailCodeService(new JdbcEmailCodeRepository(dataSource), mail);
+ }
+
+ @Bean
+ SecurityFilterChain account(HttpSecurity http) {
+ http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
+ .formLogin(Customizer.withDefaults())
+ .mfa(Customizer.withDefaults());
+ return http.build();
+ }
+// end::backend-security-email-code[]
+}
diff --git a/docs/demos/backend/src/main/resources/application.properties b/docs/demos/backend/src/main/resources/application.properties
index b66d768681b..0eb28219dc0 100644
--- a/docs/demos/backend/src/main/resources/application.properties
+++ b/docs/demos/backend/src/main/resources/application.properties
@@ -13,3 +13,8 @@ cn1.session.store=kv
# ...and a broker for messages.
cn1.messaging.url=kafka://kafka-1:9092,kafka-2:9092
# end::backend-scale-out[]
+
+# tag::backend-mail-config[]
+cn1.mail.url=smtp://${SMTP_USER}:${SMTP_PASSWORD}@smtp.example.com:587
+cn1.mail.from=Acme
+# end::backend-mail-config[]
diff --git a/docs/demos/backend/src/main/snippets/mail-shipped.html b/docs/demos/backend/src/main/snippets/mail-shipped.html
new file mode 100644
index 00000000000..c4e94892262
--- /dev/null
+++ b/docs/demos/backend/src/main/snippets/mail-shipped.html
@@ -0,0 +1,9 @@
+
+
+
+
+
Hello customer,
+
Order 1 is on its way.
+
+
+
diff --git a/docs/developer-guide/Backend-Mail.asciidoc b/docs/developer-guide/Backend-Mail.asciidoc
new file mode 100644
index 00000000000..89dfc9192b1
--- /dev/null
+++ b/docs/developer-guide/Backend-Mail.asciidoc
@@ -0,0 +1,294 @@
+[[backend-mail]]
+== Backend email
+
+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:
+
+[source,properties]
+----
+include::../demos/backend/src/main/resources/application.properties[tag=backend-mail-config]
+----
+
+`cn1.mail.from` is the sender of every mail that doesn't name its own. Inject
+`MailSender` like any other bean:
+
+[source,java,role=cn1-no-try]
+----
+include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/mail/Signup.java[tag=backend-mail-send,indent=0]
+----
+
+`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 <> 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 `4xx` reply, a timeout or a dropped
+ connection, is retried.
+* A permanent failure, a `5xx` reply, 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:
+
+[source,java,role=cn1-no-try]
+----
+include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/mail/Receipts.java[tag=backend-mail-now,indent=0]
+----
+
+A mail sent with `sendNow` isn't part of the caller's transaction and isn't
+retried.
+
+[cols="2,1,3", options="header"]
+|===
+| Key | Default | What it sets
+
+| `cn1.mail.retry.max-attempts`
+| 6
+| Attempts before a mail is given up on.
+
+| `cn1.mail.retry.backoff`
+| 30000
+| Milliseconds before the second attempt.
+
+| `cn1.mail.retry.multiplier`
+| 4
+| What each later wait is multiplied by.
+
+| `cn1.mail.retry.max-backoff`
+| 3600000
+| The longest wait.
+
+| `cn1.mail.outbox.sweep`
+| 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
+<>. Put the template under `templates/mail`:
+
+[source,html]
+----
+include::../demos/backend/src/main/snippets/mail-shipped.html[tag=backend-mail-template-html,indent=0]
+----
+
+[source,java,role=cn1-no-try]
+----
+include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/mail/Receipts.java[tag=backend-mail-template,indent=0]
+----
+
+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
+
+[source,java,role=cn1-no-try]
+----
+include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/mail/Receipts.java[tag=backend-mail-attach,indent=0]
+----
+
+`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
+
+[cols="2,3", options="header"]
+|===
+| `cn1.mail.url` | Sends through
+
+| `smtp://user:password@host:587`
+| SMTP with STARTTLS. Every provider with an SMTP endpoint, and any relay.
+
+| `smtps://user:password@host:465`
+| SMTP over TLS from the first byte.
+
+| `ses://eu-west-1`
+| The Amazon SES API in that region.
+
+| `log:`
+| Nothing. Each mail's headers and text are written to the log.
+
+| `memory:`
+| 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.
+
+[cols="2,1,3", options="header"]
+|===
+| Key | Default | What it sets
+
+| `cn1.mail.smtp.auth`
+|
+| `plain`, `login` or `xoauth2`. Unset takes what the server offers. With
+ `xoauth2` the password is an OAuth 2 access token.
+
+| `cn1.mail.smtp.helo`
+| `localhost`
+| The name the client gives for itself.
+
+| `cn1.mail.smtp.ca`
+|
+| A PEM file of certificates to trust in place of the system's.
+
+| `cn1.mail.timeout.connect`
+| 5000
+| Milliseconds a connection may take to open.
+
+| `cn1.mail.timeout.read`
+| 60000
+| Milliseconds a reply may take.
+
+| `cn1.mail.pool.max`
+| 2
+| Idle connections kept for the next mail.
+
+| `cn1.mail.pool.idle`
+| 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 <>. 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:
+
+[source,java,role=cn1-no-try]
+----
+include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/mail/SignupTest.java[tag=backend-mail-test,indent=0]
+----
+
+`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 <> 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.url` in 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 `sendNow` in a request.* It holds the request for the whole SMTP
+ exchange. Use `send` unless 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.
diff --git a/docs/developer-guide/Backend-Security.asciidoc b/docs/developer-guide/Backend-Security.asciidoc
index 35da9e64611..91c07b30153 100644
--- a/docs/developer-guide/Backend-Security.asciidoc
+++ b/docs/developer-guide/Backend-Security.asciidoc
@@ -1340,6 +1340,41 @@ Codes are six digits that change every 30 seconds, which is what authenticator
apps expect. One step either side of the current one is accepted, to allow for
a clock that's off. A code is good once.
+[[backend-security-email-code]]
+==== A code by mail
+
+A user without an authenticator app can be mailed a code. Declare an
+`EmailCodeService` beside the `TotpService`, or in its place:
+
+[source,java,role=cn1-no-try]
+----
+include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/security/MailSecurityConfig.java[tag=backend-security-email-code,indent=0]
+----
+
+`emailCodes.enable(username)` turns it on for a user, and `disable` turns it
+off. A user with only this factor is mailed a code as soon as their password
+is accepted. A user who also has an authenticator app is asked for the app's
+code, and the page has a button that mails one. Either is accepted.
+
+A mailed code is six digits and good for ten minutes, once. Five wrong guesses
+spend it, whoever makes them. A new code can be asked for every 30 seconds and
+replaces the one before it, but it doesn't bring five more guesses: the count
+carries over until a code is entered correctly or the last one runs out. Once
+the five are spent, no code is mailed until then. The server stores a
+hash of the code, bound to the user it was made for. `validitySeconds`,
+`maxAttempts` and `resendSeconds` change the numbers, and `template` renders
+the mail from a template of your own, which is given `code`, `username` and
+`minutes`.
+
+The code goes to the address `EmailAddressResolver` gives for the user. The
+default takes a username that's an address as it is. An
+`EmailVerificationService` is such a resolver too, and passing it to
+`addressResolver` sends codes only to addresses users have proved are theirs.
+
+A mailed code is as strong as the user's mailbox. It stops a stolen password
+from being enough. It doesn't stop someone who reads the user's mail, which an
+authenticator app or a passkey does.
+
==== Recovery codes
`RecoveryCodeService.generate` makes ten codes that stand in for a lost phone.
@@ -1486,6 +1521,91 @@ holds a secret: a public key verifies and can't sign. A user who signs in with
a passkey is looked up in the application's `UserDetailsService` for their
authorities.
+[[backend-security-password-reset]]
+=== Resetting a forgotten password
+
+`passwordReset` adds the two pages a forgotten password needs, and mails the
+link between them through the `MailSender` of <>:
+
+[source,java,role=cn1-no-try]
+----
+include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/security/MailSecurityConfig.java[tag=backend-security-reset,indent=0]
+----
+
+`GET /forgot-password` asks for the username. Posting it mails a link to
+`/reset-password`, where the user chooses a new password. The link works once
+and for 30 minutes. A newer link replaces an older one, and after a reset the
+user's remembered sign-ins are removed, so a browser that was remembered has
+to sign in again. A session that's already signed in stays until it ends.
+`requestUrl` and `resetUrl` move the pages, and `servePages(false)` leaves the
+`GET` of both to controllers of your own, which post to the same paths with
+the fields `username`, and `token`, `password` and `confirm`.
+
+The login page the layer generates gains a "Forgot your password?" link to the
+request page, and tells a user the reset sends back that the password was
+changed. A login page of your own links there itself; the reset returns to it
+with `?reset` in the address.
+
+What the layer does so that the pages can't be turned against your users:
+
+* *The answer never says whether an account exists.* A username nobody has
+ gets the same redirect, and no mail. An account that's disabled or has no
+ address is answered the same way.
+* *Asking is rate limited.* Five requests in fifteen minutes from one network,
+ and five for one username, then `429 Too Many Requests`. A `RateLimiter`
+ bean is used when there is one, so the limit holds across instances. See
+ <>.
+* *Only a hash of the token is stored.* A copy of the database can't be used to
+ reset anyone's password.
+* *The link is never built from the request's `Host` header.* That header is
+ whatever the client sent, and a link built from it would mail your user a
+ link to someone else's site. Set `cn1.security.base-url=https://example.com`,
+ or call `baseUrl(...)`. Without it only a request to `localhost` is served,
+ which is enough for development, and any other fails naming the setting.
+* *The pages aren't cached and send no referrer,* so the token in the address
+ doesn't leave the browser.
+
+The new password has to be eight characters or more. `minimumLength` on the
+service changes that, `validityMinutes` the life of a link, and `template`
+names a mail template of your own, which is given `link`, `username` and
+`minutes`. The mail goes to the address `EmailAddressResolver` gives for the
+user, as in <>.
+
+[[backend-security-email-verification]]
+=== Verifying an email address
+
+An address a user typed at sign-up is a claim. `EmailVerificationService`
+turns it into a fact by mailing a link to it:
+
+[source,java,role=cn1-no-try]
+----
+include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/security/MailSecurityConfig.java[tag=backend-security-verify,indent=0]
+----
+
+Send the mail when the account is created, or when the user changes their
+address:
+
+[source,java,role=cn1-no-try]
+----
+include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/security/MailSecurityConfig.java[tag=backend-security-verify-send,indent=0]
+----
+
+`emailVerification` serves `/verify-email`, the page the link opens. It has
+one button. Pressing it marks the address as verified and redirects to
+`/?verified`, or to `successUrl`. Opening the link isn't enough by itself,
+because mail gateways open every link in a message to scan it, before the
+reader sees the message. The user doesn't have to be signed in when they
+follow the link. Most people open mail on a different device from the one
+they signed up on. A link works once and for a day, and a link that's spent
+or made up gets a `400` page. A link proves only the address it was mailed
+to.
+
+`verification.verified()` is an authorization rule: the request needs a
+signed-in user whose address is verified. `isVerified(username)` answers the
+same question in code. Sending again for a user replaces their pending address
+and their earlier link. An address stays verified until `send` is called with
+another or `forget` removes it.
+
=== Remembering a user
`rememberMe` gives a user who ticks the box on the login form a cookie that
@@ -1622,6 +1742,10 @@ are the same on SQLite, PostgreSQL, and MySQL.
| 12
| `cn1_persistent_logins.user_agent`, `cn1_persistent_logins.ip_address`
| The device each remembered sign-in came from.
+
+| 13
+| `cn1_one_time_token`, `cn1_mfa_email`, `cn1_verified_email`
+| `JdbcOneTimeTokenRepository`, `JdbcEmailCodeRepository`, `JdbcVerifiedEmailRepository`
|===
To use tables you already have, give their names in `application.properties`.
@@ -1977,6 +2101,11 @@ read by the build, because it decides what's linked into the server.
password is set. The name is `user` unless set, and the roles are separated
by commas. A password without a `{id}` prefix is taken as `{noop}`.
+| `cn1.security.base-url`
+| The address the server is reached at, such as `https://example.com`, with
+ nothing after the host. Mailed links start with it. See
+ <>.
+
| `cn1.security.password.maxConcurrent`
| The most passwords checked at one time. No bound unless set.
diff --git a/docs/developer-guide/Backend-Testing.asciidoc b/docs/developer-guide/Backend-Testing.asciidoc
index 7eb4ae2c285..cd13350e459 100644
--- a/docs/developer-guide/Backend-Testing.asciidoc
+++ b/docs/developer-guide/Backend-Testing.asciidoc
@@ -278,6 +278,15 @@ interface the application never implements and gets the generated one.
Both slices take `properties`, `profile` and `classes` as `@BackendTest` does, and
both run compiled, as the next sections describe.
+=== Mail in a test
+
+A test's application sends no mail unless the test names a `cn1.mail.url`.
+Every mail is kept in memory instead, and `MailSender.sent()` returns them, so
+a test can assert on the recipient, the subject and the rendered body. The
+flows that mail a link or a code, such as a password reset, are tested the same
+way: read the link out of the captured mail and follow it. <> has
+an example.
+
=== Turning production features on
A test's application starts without the management endpoints, the MCP endpoint
diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc
index a0601ed86f3..6cd2acdd726 100644
--- a/docs/developer-guide/developer-guide.asciidoc
+++ b/docs/developer-guide/developer-guide.asciidoc
@@ -143,6 +143,8 @@ include::Backend-Scheduling.asciidoc[]
include::Backend-Messaging.asciidoc[]
+include::Backend-Mail.asciidoc[]
+
include::Backend-Observability.asciidoc[]
include::Backend-MCP.asciidoc[]
diff --git a/maven/backend/src/test/java/com/codename1/backend/security/MailFlowsTest.java b/maven/backend/src/test/java/com/codename1/backend/security/MailFlowsTest.java
new file mode 100644
index 00000000000..ecc66343c0d
--- /dev/null
+++ b/maven/backend/src/test/java/com/codename1/backend/security/MailFlowsTest.java
@@ -0,0 +1,492 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.backend.security;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.codename1.backend.HttpServer;
+import com.codename1.backend.mail.Mail;
+import com.codename1.backend.mail.MailSender;
+import com.codename1.backend.mvc.Model;
+import com.codename1.backend.security.SecuredServer.Reply;
+import com.codename1.backend.security.crypto.PasswordEncoderFactories;
+import com.codename1.backend.security.core.userdetails.InMemoryUserDetailsManager;
+import com.codename1.backend.security.core.userdetails.User;
+import com.codename1.backend.security.mail.EmailVerificationService;
+import com.codename1.backend.security.mail.InMemoryOneTimeTokenRepository;
+import com.codename1.backend.security.mail.InMemoryVerifiedEmailRepository;
+import com.codename1.backend.security.mail.OneTimeTokenService;
+import com.codename1.backend.security.mail.PasswordResetService;
+import com.codename1.backend.security.mfa.EmailCodeService;
+import com.codename1.backend.security.mfa.InMemoryEmailCodeRepository;
+import com.codename1.backend.security.mfa.InMemoryRecoveryCodeRepository;
+import com.codename1.backend.security.mfa.RecoveryCodeService;
+import com.codename1.impl.backend.MailRuntime;
+import com.codename1.impl.backend.security.SecuritySupport;
+import java.io.IOException;
+import java.util.List;
+import java.util.Properties;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/// The flows that end in an inbox, through a real chain: the forgotten
+/// password pages, the link that proves an address, and the sign-in code that
+/// is mailed. Mail lands in the memory transport, which is what a server with
+/// no `cn1.mail.url` sends through.
+class MailFlowsTest {
+ private static final String ADA = "ada@example.com";
+
+ private static final HttpServer.Handler APP = new HttpServer.Handler() {
+ @Override
+ public HttpServer.Response handle(HttpServer.Request request) {
+ Authentication who = SecuritySupport.authentication();
+ return HttpServer.Response.text(200, request.pathFrom(0) + " "
+ + (who == null ? "nobody" : who.getName()));
+ }
+ };
+
+ private static final class Ticking implements Clock {
+ long now = 1700000000000L;
+
+ @Override
+ public long currentTimeMillis() {
+ return now;
+ }
+ }
+
+ private final Ticking clock = new Ticking();
+ private final MailSender mail = new MailSender();
+ private final OneTimeTokenService tokens = new OneTimeTokenService(
+ new InMemoryOneTimeTokenRepository()).clock(clock);
+ private final InMemoryUserDetailsManager users = new InMemoryUserDetailsManager(
+ User.withUsername(ADA).password("{noop}old password").roles("USER").build(),
+ User.withUsername("ray").password("{noop}ray password").roles("USER").build());
+
+ private static Properties settings() {
+ Properties settings = SecuredServer.settings();
+ settings.setProperty("cn1.mail.from", "Accounts ");
+ // The code's own count of guesses is what these tests are about, so
+ // the sign-in's rate limit is moved out of its way.
+ settings.setProperty(MfaConfigurer.ATTEMPTS, "50");
+ settings.setProperty(MfaConfigurer.ATTEMPTS_PER_ADDRESS, "50");
+ return settings;
+ }
+
+ /// The one link in the newest mail.
+ private String link(String to) throws Exception {
+ List sent = mail.sent();
+ assertFalse(sent.isEmpty(), "nothing was mailed");
+ Mail last = sent.get(sent.size() - 1);
+ assertEquals(to, last.getTo().get(0));
+ String text = last.getText();
+ int at = text.indexOf("http");
+ assertTrue(at >= 0, text);
+ int end = at;
+ while (end < text.length() && text.charAt(end) > ' ') {
+ end++;
+ }
+ return text.substring(at, end);
+ }
+
+ /// The path and query of a link, which is what a request is made of.
+ private static String target(String link) {
+ return link.substring(link.indexOf('/', link.indexOf("//") + 2));
+ }
+
+ // -------------------------------------------------------- password reset
+
+ private SecuredServer startReset(Properties settings, String baseUrl) throws Exception {
+ PasswordResetService service = new PasswordResetService(tokens, mail, users, users,
+ PasswordEncoderFactories.createDelegatingPasswordEncoder());
+ return SecuredServer.start(settings, "dev", new Object[] {users}, APP,
+ http -> http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
+ .csrf(csrf -> csrf.disable())
+ .formLogin(Customizer.withDefaults())
+ .passwordReset(reset -> {
+ reset.service(service);
+ if (baseUrl != null) {
+ reset.baseUrl(baseUrl);
+ }
+ }).build());
+ }
+
+ @Test
+ @DisplayName("a forgotten password is replaced through a mailed link, once")
+ void passwordReset() throws Exception {
+ try (SecuredServer server = startReset(settings(), null)) {
+ mail.clearSent();
+ // The generated login page offers the way there, and says what
+ // happened to someone a reset sends back.
+ String login = server.get("/login").body;
+ assertTrue(login.contains("Forgot your password?"),
+ login);
+ assertFalse(login.contains("Your password was changed"), login);
+ assertTrue(server.get("/login?reset").body.contains("Your password was changed"));
+
+ // Both pages are served to nobody.
+ Reply ask = server.get("/forgot-password");
+ assertEquals(200, ask.status);
+ assertTrue(ask.body.contains("name=\"username\""), ask.body);
+ assertEquals("no-store", ask.header("Cache-Control"));
+
+ // An account that does not exist is answered as one that does.
+ Reply unknown = server.post("/forgot-password", "username=nobody%40example.com");
+ assertEquals("302 /forgot-password?sent",
+ unknown.status + " " + unknown.header("Location"));
+ assertEquals(0, mail.sent().size());
+ Reply known = server.post("/forgot-password", "username=ada%40example.com");
+ assertEquals("302 /forgot-password?sent",
+ known.status + " " + known.header("Location"));
+ assertEquals(1, mail.sent().size());
+ assertEquals(server.get("/forgot-password?sent").body.length() > 0, true);
+
+ // The request named localhost, so the link may.
+ String link = link(ADA);
+ assertTrue(link.startsWith("http://localhost/reset-password?token="), link);
+ Reply form = server.get(target(link));
+ assertEquals(200, form.status);
+ assertTrue(form.body.contains("name=\"password\"")
+ && form.body.contains("name=\"confirm\""), form.body);
+ assertEquals("no-referrer", form.header("Referrer-Policy"));
+ String token = link.substring(link.indexOf("token=") + 6);
+
+ // A password that is refused keeps the link alive.
+ Reply shortOne = server.post("/reset-password",
+ "token=" + token + "&password=abc&confirm=abc");
+ assertEquals("302 /reset-password?token=" + token + "&invalid",
+ shortOne.status + " " + shortOne.header("Location"));
+ Reply differ = server.post("/reset-password",
+ "token=" + token + "&password=new+password&confirm=another+one");
+ assertEquals("302 /reset-password?token=" + token + "&invalid",
+ differ.status + " " + differ.header("Location"));
+
+ Reply done = server.post("/reset-password",
+ "token=" + token + "&password=new+password&confirm=new+password");
+ assertEquals("302 /login?reset", done.status + " " + done.header("Location"));
+ // Resetting a password signs nobody in.
+ assertEquals("/login", server.get("/me", "Accept", "text/html").header("Location"));
+
+ // The link is spent.
+ Reply again = server.post("/reset-password",
+ "token=" + token + "&password=third+password&confirm=third+password");
+ assertEquals("302 /forgot-password?expired",
+ again.status + " " + again.header("Location"));
+
+ assertEquals("/login?error", server.post("/login",
+ "username=ada%40example.com&password=old+password").header("Location"));
+ // Back to the page that was asked for before the sign-in.
+ assertEquals("/me", server.post("/login",
+ "username=ada%40example.com&password=new+password").header("Location"));
+ assertEquals("/me " + ADA, server.get("/me").body);
+
+ // What the health report and the outbox gauge read.
+ MailRuntime.Probe probe = MailRuntime.probe();
+ assertNotNull(probe);
+ assertEquals("memory", probe.transport());
+ assertTrue(probe.isHealthy());
+ assertEquals(0, probe.pending());
+ }
+ }
+
+ @Test
+ @DisplayName("a reset link dies with its half hour, and a made-up one is refused")
+ void resetLinksExpire() throws Exception {
+ try (SecuredServer server = startReset(settings(), null)) {
+ mail.clearSent();
+ server.post("/forgot-password", "username=ada%40example.com");
+ String token = link(ADA).substring(link(ADA).indexOf("token=") + 6);
+ clock.now += 31 * 60000L;
+ Reply late = server.post("/reset-password",
+ "token=" + token + "&password=new+password&confirm=new+password");
+ assertEquals("302 /forgot-password?expired",
+ late.status + " " + late.header("Location"));
+ Reply none = server.get("/reset-password");
+ assertEquals("302 /forgot-password?expired",
+ none.status + " " + none.header("Location"));
+ Reply invented = server.post("/reset-password",
+ "token=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+ + "&password=new+password&confirm=new+password");
+ assertEquals("302 /forgot-password?expired",
+ invented.status + " " + invented.header("Location"));
+ assertEquals("/", server.post("/login",
+ "username=ada%40example.com&password=old+password").header("Location"));
+ }
+ }
+
+ @Test
+ @DisplayName("asking for links is rate limited, by network and by account")
+ void resetRequestsAreLimited() throws Exception {
+ try (SecuredServer server = startReset(settings(), null)) {
+ mail.clearSent();
+ for (int iter = 0 ; iter < 5 ; iter++) {
+ assertEquals(302, server.post("/forgot-password",
+ "username=ada%40example.com").status);
+ }
+ Reply refused = server.post("/forgot-password", "username=ada%40example.com");
+ assertEquals(429, refused.status, refused.toString());
+ assertNotNull(refused.header("Retry-After"), refused.toString());
+ assertEquals(5, mail.sent().size());
+ }
+ }
+
+ @Test
+ @DisplayName("a link is never built from the Host header of a request")
+ void theLinkNamesTheConfiguredAddress() throws Exception {
+ // Configured in code.
+ try (SecuredServer server = startReset(settings(), "https://shop.example.com/")) {
+ mail.clearSent();
+ server.post("/forgot-password", "username=ada%40example.com",
+ "Host", "attacker.example");
+ assertTrue(link(ADA).startsWith("https://shop.example.com/reset-password?token="),
+ link(ADA));
+ }
+ // Configured in the properties.
+ Properties settings = settings();
+ settings.setProperty(PasswordResetConfigurer.BASE_URL, "https://app.example.com");
+ try (SecuredServer server = startReset(settings, null)) {
+ mail.clearSent();
+ server.post("/forgot-password", "username=ada%40example.com",
+ "Host", "attacker.example");
+ assertTrue(link(ADA).startsWith("https://app.example.com/reset-password?token="),
+ link(ADA));
+ }
+ // Not configured: localhost is served, and anything else fails
+ // naming the setting, before any account is looked at.
+ try (SecuredServer server = startReset(settings(), null)) {
+ mail.clearSent();
+ IllegalStateException refused = assertThrows(IllegalStateException.class,
+ () -> server.post("/forgot-password", "username=ada%40example.com",
+ "Host", "attacker.example"));
+ assertTrue(refused.getMessage().contains(PasswordResetConfigurer.BASE_URL),
+ refused.getMessage());
+ assertEquals(0, mail.sent().size());
+ }
+ assertThrows(Exception.class,
+ () -> startReset(settings(), "https://shop.example.com/app").close());
+ }
+
+ @Test
+ @DisplayName("a mail's template is rendered when it is sent, and its text derived from it")
+ void templatesRenderAtSendTime() throws Exception {
+ try (SecuredServer server = startReset(settings(), null)) {
+ mail.clearSent();
+ // No templates in this application: the mail says where one goes.
+ MailRuntime.install(null);
+ IOException none = assertThrows(IOException.class, () -> mail.send(
+ Mail.to(ADA).subject("Hello").template("mail/welcome", new Model()).build()));
+ assertTrue(none.getMessage().contains("templates/mail/welcome.html"),
+ none.getMessage());
+
+ // What the generated wiring installs over the compiled templates.
+ MailRuntime.install(new MailRuntime.TemplateRenderer() {
+ @Override
+ public byte[] render(String view, Model model) throws IOException {
+ return ("
",
+ sent.getHtml());
+ assertTrue(sent.getText().contains("mail/welcome for Ada")
+ && sent.getText().contains("https://example.com/a"), sent.getText());
+ assertEquals("Accounts ", sent.getFrom());
+
+ // Text of the caller's own is kept.
+ mail.send(Mail.to(ADA).subject("Hello").text("Plain words")
+ .template("mail/welcome", new Model().addAttribute("name", "Ada"))
+ .build());
+ assertEquals("Plain words", mail.sent().get(1).getText());
+ } finally {
+ MailRuntime.install(null);
+ }
+ }
+ }
+
+ // ---------------------------------------------------- email verification
+
+ @Test
+ @DisplayName("following the mailed link proves the address, and opens what asks for it")
+ void emailVerification() throws Exception {
+ EmailVerificationService service = new EmailVerificationService(tokens, mail,
+ new InMemoryVerifiedEmailRepository()).clock(clock);
+ try (SecuredServer server = SecuredServer.start(settings(), "dev", new Object[] {users},
+ APP, http -> http.authorizeHttpRequests(auth -> auth
+ .requestMatchers("/orders").access(service.verified())
+ .anyRequest().authenticated())
+ .csrf(csrf -> csrf.disable())
+ .formLogin(Customizer.withDefaults())
+ .emailVerification(verify -> verify.service(service)).build())) {
+ mail.clearSent();
+ // No reset in this chain: its login page offers none, and has
+ // nothing to say to a ?reset somebody typed.
+ String login = server.get("/login?reset").body;
+ assertFalse(login.contains("Forgot your password")
+ || login.contains("Your password was changed"), login);
+ server.post("/login", "username=ray&password=ray+password");
+ assertEquals("/me ray", server.get("/me").body);
+ assertEquals(403, server.get("/orders").status);
+
+ service.send("ray", "ray@example.com", "http://localhost/verify-email");
+ String link = link("ray@example.com");
+ assertFalse(service.isVerified("ray"));
+
+ // A link that was never mailed.
+ assertEquals(400, server.get("/verify-email?token=nothing").status);
+ assertEquals(400, server.get("/verify-email").status);
+ assertFalse(service.isVerified("ray"));
+
+ // The real one, followed in a browser that is not signed in.
+ String session = server.cookies.remove("CN1SESSION");
+ // Opening it proves nothing: a mail gateway opens every link.
+ Reply page = server.get(target(link));
+ String token = link.substring(link.indexOf("token=") + 6);
+ assertEquals(200, page.status);
+ assertTrue(page.body.contains("\n");
+ // Offered only by a chain that mails resets, which is one that has a
+ // way of sending mail.
+ if (!resetPages.isEmpty()) {
+ page.append("