Send email from Spring Boot

JavaMailSender means SMTP, and SMTP means a connection pool and outbound ports your platform may not give you. This page uses the HTTP API through RestClient, wraps it in a service bean, and marks the send @Async so the controller returns straight away.

Setup

  1. 01

    Create a key

    https://emails.sh/dashboard issues a key starting with esh_.

  2. 02

    Bind it with @ConfigurationProperties

    application.yml reads ${EMAILSSH_API_KEY} from the environment, and a typed record gives you a startup failure if it is missing.

  3. 03

    Register a RestClient bean

    RestClient ships with Spring Framework 6.1 (Boot 3.2+). Set the base URL and the Authorization header once on the builder.

  4. 04

    Turn on @EnableAsync

    Without it @Async is ignored and the method runs inline on the request thread, which is exactly what you were trying to avoid.

Install
implementation "org.springframework.boot:spring-boot-starter-web"
src/main/resources/application.yml
emailssh:
  api-key: ${EMAILSSH_API_KEY}
  from: "Acme <hello@acme.com>"
  base-url: https://emails.sh/v1

The service

src/main/java/com/acme/email/EmailService.java (Spring Boot 3.2+)

The service
package com.acme.email;

import java.util.List;
import java.util.Map;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;

@ConfigurationProperties(prefix = "emailssh")
record EmailsshProperties(String apiKey, String from, String baseUrl) {}

@Configuration
@EnableAsync
class EmailConfig {

    @Bean
    RestClient emailsshClient(RestClient.Builder builder, EmailsshProperties props) {
        return builder
                .baseUrl(props.baseUrl())
                .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + props.apiKey())
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();
    }
}

@Service
public class EmailService {

    private static final Logger log = LoggerFactory.getLogger(EmailService.class);

    private final RestClient client;
    private final EmailsshProperties props;

    public EmailService(RestClient emailsshClient, EmailsshProperties props) {
        this.client = emailsshClient;
        this.props = props;
    }

    /** Runs on the async executor, so the controller thread is free immediately. */
    @Async
    public void sendWelcome(String to, String name) {
        Map<String, Object> body = Map.of(
                "from", props.from(),
                "to", List.of(to),
                "subject", "Welcome to Acme",
                "html", "<p>Hi " + escape(name) + ", your Acme account is ready.</p>",
                "text", "Hi " + name + ", your Acme account is ready.",
                "idempotency_key", "welcome:" + to.toLowerCase());

        try {
            Map<?, ?> response = client.post()
                    .uri("/emails")
                    .body(body)
                    .retrieve()
                    .body(Map.class);

            log.info("emails.sh queued {}", response == null ? "unknown" : response.get("id"));
        } catch (RestClientResponseException error) {
            // The response body is prose and says what to do next, so keep it.
            log.error("emails.sh {}: {}", error.getStatusCode(), error.getResponseBodyAsString());
        }
    }

    private static String escape(String value) {
        return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
    }
}

The controller

src/main/java/com/acme/email/SignupController.java

The controller
package com.acme.email;

import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class SignupController {

    private final EmailService email;

    public SignupController(EmailService email) {
        this.email = email;
    }

    record SignupRequest(@NotBlank @Email String email, String name) {}

    @PostMapping("/signup")
    public ResponseEntity<Void> signup(@Valid @RequestBody SignupRequest request) {
        email.sendWelcome(request.email(), request.name() == null ? "there" : request.name());
        return ResponseEntity.accepted().build();
    }
}

Enabling the properties record

src/main/java/com/acme/AcmeApplication.java

Enabling the properties record
package com.acme;

import com.acme.email.EmailsshProperties;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;

@SpringBootApplication
@EnableConfigurationProperties(EmailsshProperties.class)
public class AcmeApplication {

    public static void main(String[] args) {
        SpringApplication.run(AcmeApplication.class, args);
    }
}

Worth knowing

01

@Async does nothing without @EnableAsync

Spring only creates the proxy that moves the call onto another thread when async is enabled. Miss the annotation and the method runs inline, on the request thread, with no error to tell you.

02

@Async only works through the proxy

Calling this.sendWelcome() from another method on the same bean bypasses the proxy and runs synchronously. Inject the service and call it from a different bean, as the controller does.

03

RestClient needs Boot 3.2 or later

On Boot 3.1 and earlier use RestTemplate or WebClient with the same base URL and header. The request body is unchanged.

04

Escape anything a user typed

Concatenating a name into an HTML string is the same injection you would guard against in a web page. Escape it, or render the body with Thymeleaf and let the template engine do it.

Questions

Do I need spring-boot-starter-mail?

No. That starter configures JavaMailSender for SMTP. On the HTTP API, spring-boot-starter-web is all you need.

RestClient, WebClient, or RestTemplate?

RestClient on Boot 3.2+ for blocking code. WebClient if your app is already reactive. RestTemplate still works and is not deprecated, only no longer the recommendation.

How do I test this without sending?

MockRestServiceServer against the RestClient builder, or point base-url at a local stub in application-test.yml.

The rest of the API

POST /v1/emails sends one and POST /v1/emails/batch sends up to 100 in a call. GET /v1/emails/:id returns the delivery events for a message, which is how you answer "did it arrive" without a support ticket. Webhooks push the same events to you as they happen.