Warning
This article was automatically translated by OpenAI (gpt-4.1).It may be edited eventually, but please be aware that it may contain incorrect information at this time.
There is a Java SDK for calling the SendGrid API, and AutoConfiguration is also provided for Spring Boot, but here I’ll jot down how to call the API directly using Spring’s RestClient.
Personally, I don’t use the SendGrid Java SDK for reasons such as:
- The code style doesn’t suit my preferences
- It’s cumbersome to change the recipient for development/testing (can’t switch endpoints with a single property)
- It uses an old Apache HttpClient
If all you want to do is send an email, you just need to call the API at https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send, and for basic usage, a simple REST API access is sufficient. There’s no need to use the SDK; calling the API directly with Spring’s RestClient is enough.
Also, by using RestClient, you can use Interceptors, which allows you to leverage existing features such as:
- Client-side access logging
- Observability (Tracing, Metrics)
- Retry
This means you can operate it the same way as other API calls.
Table of Contents
- Creating the Project
- Starting a SendGrid Mock with Testcontainers
- Creating an Integration Test
- Switching to SendGrid
Creating the Project
Create a Spring Boot project using Spring Initializr.
curl -s https://start.spring.io/starter.tgz \
-d artifactId=demo-sendgrid \
-d name=demo-sendgrid \
-d baseDir=demo-sendgrid \
-d packageName=com.example \
-d dependencies=web,actuator,configuration-processor,prometheus,testcontainers \
-d type=maven-project \
-d applicationName=DemoSendgridApplication | tar -xzvf -
cd demo-sendgrid
First, make the SendGrid access information configurable via properties.
cat <<'EOF' > src/main/java/com/example/SendGridProps.java
package com.example;
import java.net.URI;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
@ConfigurationProperties(prefix = "sendgrid")
public record SendGridProps(String apiKey, @DefaultValue("https://api.sendgrid.com") URI baseUrl,
@DefaultValue("noreply@example.com") String from) {
}
EOF
Add @ConfigurationPropertiesScan to the main class so that SendGridProps can be scanned.
cat <<'EOF' > src/main/java/com/example/DemoSendgridApplication.java
package com.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class DemoSendgridApplication {
public static void main(String[] args) {
SpringApplication.run(DemoSendgridApplication.class, args);
}
}
EOF
Now for the main topic. According to the documentation for the /v3/mail/send API, create the request body.
If you only need to set the recipient, subject, body, and reply-to, a Map is sufficient. In this example, the direct API access is written in HelloController, but it’s better to create a class like SendGridSender.
cat <<'EOF' > src/main/java/com/example/HelloController.java
package com.example;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
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;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
@RestController
public class HelloController {
private final RestClient restClient;
private final SendGridProps props;
public HelloController(RestClient.Builder restClientBuilder, SendGridProps props) {
this.restClient = restClientBuilder.baseUrl(props.baseUrl())
.defaultHeaders(headers -> headers.setBearerAuth(props.apiKey()))
.defaultStatusHandler(__ -> true, (req, res) -> {
})
.build();
this.props = props;
}
@PostMapping(path = "/send")
public Map<String, Object> send(@RequestBody Message message) {
ResponseEntity<String> response = this.restClient.post()
.uri("/v3/mail/send")
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("personalizations",
List.of(Map.of("to", List.of(Map.of("email", message.to())), "subject", message.subject())), "from",
Map.of("email", this.props.from()), "content",
List.of(Map.of("type", "text/plain", "value", message.content()))))
.retrieve()
.toEntity(String.class);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Failed to send a mail: " + response.getBody());
}
return Map.of("message", "Sent");
}
public record Message(String to, String subject, String content) {
}
}
EOF
Starting a SendGrid Mock with Testcontainers
For testing and verification, instead of calling the actual SendGrid API, I want to use a mock service.
SendGrid MailDev was perfect for this purpose.
It seems to wrap the SendGrid API on the frontend of MailDev, a mock SMTP server.
It’s also convenient that Docker images are available for both amd and arm.
Add the configuration to start SendGrid MailDev with Testcontainers.
cat <<'EOF' > src/test/java/com/example/TestcontainersConfiguration.java
package com.example;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.DynamicPropertyRegistrar;
import org.testcontainers.containers.FixedHostPortGenericContainer;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.output.Slf4jLogConsumer;
import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy;
@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {
@Bean
FixedHostPortGenericContainer<?> sendgrid(@Value("${maildev.port:31080}") int maildevPort) {
var container = new FixedHostPortGenericContainer<>("ykanazawa/sendgrid-maildev")
.withEnv("SENDGRID_DEV_API_SERVER", ":3030")
.withEnv("SENDGRID_DEV_API_KEY", "SG.test")
.withEnv("SENDGRID_DEV_SMTP_SERVER", "127.0.0.1:1025")
.withExposedPorts(3030, 1080)
.waitingFor(new LogMessageWaitStrategy().withRegEx(".*sendgrid-dev entered RUNNING state.*")
.withStartupTimeout(Duration.of(60, ChronoUnit.SECONDS)))
.withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("sendgrid-maildev")));
return maildevPort > 0 ? container.withFixedExposedPort(maildevPort, 1080) : container;
}
@Bean
DynamicPropertyRegistrar dynamicPropertyRegistrar(GenericContainer<?> sendgrid) {
return registry -> {
registry.add("sendgrid.base-url", () -> "http://127.0.0.1:" + sendgrid.getMappedPort(3030));
registry.add("sendgrid.api-key", () -> "SG.test");
registry.add("maildev.port", () -> sendgrid.getMappedPort(1080));
};
}
}
EOF
Since I want to check the mail reception screen in the browser, I fixed the MailDev Web UI port to 31080.
To use Testcontainers for local development, start the application with the test-run command.
./mvnw spring-boot:test-run
Or run src/test/java/com/example/TestDemoSendgridApplication.java in your IDE.
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.4.5)
2025-05-19T10:46:18.092+09:00 INFO 57122 --- [demo-sendgrid] [ main] com.example.DemoSendgridApplication : Starting DemoSendgridApplication using Java 21.0.6 with PID 57122 (/private/tmp/demo-sendgrid/target/classes started by toshiaki in /private/tmp/demo-sendgrid)
2025-05-19T10:46:18.093+09:00 INFO 57122 --- [demo-sendgrid] [ main] com.example.DemoSendgridApplication : No active profile set, falling back to 1 default profile: "default"
2025-05-19T10:46:18.402+09:00 INFO 57122 --- [demo-sendgrid] [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port 8080 (http)
2025-05-19T10:46:18.407+09:00 INFO 57122 --- [demo-sendgrid] [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2025-05-19T10:46:18.407+09:00 INFO 57122 --- [demo-sendgrid] [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/10.1.40]
2025-05-19T10:46:18.423+09:00 INFO 57122 --- [demo-sendgrid] [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2025-05-19T10:46:18.423+09:00 INFO 57122 --- [demo-sendgrid] [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 317 ms
2025-05-19T10:46:18.472+09:00 INFO 57122 --- [demo-sendgrid] [ main] org.testcontainers.images.PullPolicy : Image pull policy will be performed by: DefaultPullPolicy()
2025-05-19T10:46:18.473+09:00 INFO 57122 --- [demo-sendgrid] [ main] o.t.utility.ImageNameSubstitutor : Image name substitution will be performed by: DefaultImageNameSubstitutor (composite of 'ConfigurationFileImageNameSubstitutor' and 'PrefixingImageNameSubstitutor')
2025-05-19T10:46:18.479+09:00 INFO 57122 --- [demo-sendgrid] [ main] org.testcontainers.DockerClientFactory : Testcontainers version: 1.20.6
2025-05-19T10:46:18.542+09:00 INFO 57122 --- [demo-sendgrid] [ main] o.t.d.DockerClientProviderStrategy : Loaded org.testcontainers.dockerclient.UnixSocketClientProviderStrategy from ~/.testcontainers.properties, will try it first
2025-05-19T10:46:18.627+09:00 INFO 57122 --- [demo-sendgrid] [ main] o.t.d.DockerClientProviderStrategy : Found Docker environment with local Unix socket (unix:///var/run/docker.sock)
2025-05-19T10:46:18.627+09:00 INFO 57122 --- [demo-sendgrid] [ main] org.testcontainers.DockerClientFactory : Docker host IP address is localhost
2025-05-19T10:46:18.639+09:00 INFO 57122 --- [demo-sendgrid] [ main] org.testcontainers.DockerClientFactory : Connected to docker:
Server Version: 27.5.1
API Version: 1.47
Operating System: OrbStack
Total Memory: 96439 MB
2025-05-19T10:46:18.679+09:00 INFO 57122 --- [demo-sendgrid] [ main] tc.testcontainers/ryuk:0.11.0 : Creating container for image: testcontainers/ryuk:0.11.0
2025-05-19T10:46:18.699+09:00 INFO 57122 --- [demo-sendgrid] [ main] o.t.utility.RegistryAuthLocator : Credential helper/store (docker-credential-osxkeychain) does not have credentials for https://index.docker.io/v1/
2025-05-19T10:46:18.781+09:00 INFO 57122 --- [demo-sendgrid] [ main] tc.testcontainers/ryuk:0.11.0 : Container testcontainers/ryuk:0.11.0 is starting: 785a37255ea791b78ae1e2171d33a284d4a76ba532fe6c7ddd1991ab63ec540a
2025-05-19T10:46:18.938+09:00 INFO 57122 --- [demo-sendgrid] [ main] tc.testcontainers/ryuk:0.11.0 : Container testcontainers/ryuk:0.11.0 started in PT0.258628S
2025-05-19T10:46:18.940+09:00 INFO 57122 --- [demo-sendgrid] [ main] o.t.utility.RyukResourceReaper : Ryuk started - will monitor and terminate Testcontainers containers on JVM exit
2025-05-19T10:46:18.940+09:00 INFO 57122 --- [demo-sendgrid] [ main] org.testcontainers.DockerClientFactory : Checking the system...
2025-05-19T10:46:18.940+09:00 INFO 57122 --- [demo-sendgrid] [ main] org.testcontainers.DockerClientFactory : ✔︎ Docker server version should be at least 1.6.0
2025-05-19T10:46:18.942+09:00 INFO 57122 --- [demo-sendgrid] [ main] tc.ykanazawa/sendgrid-maildev:latest : Creating container for image: ykanazawa/sendgrid-maildev:latest
2025-05-19T10:46:18.977+09:00 INFO 57122 --- [demo-sendgrid] [ main] tc.ykanazawa/sendgrid-maildev:latest : Container ykanazawa/sendgrid-maildev:latest is starting: b084cbf0c96ec57eaeca647dd574415ccb89e1fd852dc031e83c7b7d9563bcc8
2025-05-19T10:46:19.147+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDERR: /usr/lib/python3.11/site-packages/supervisor/options.py:474: UserWarning: Supervisord is running as root and it is searching for its configuration file in default locations (including its current working directory); you probably want to specify a "-c" argument specifying an absolute path to a configuration file for improved security.
2025-05-19T10:46:19.147+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDERR: self.warnings.warn(
2025-05-19T10:46:19.150+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: 2025-05-19 01:46:19,150 CRIT Supervisor is running as root. Privileges were not dropped because no user is specified in the config file. If you intend to run as root, you can set user=root in the config file to avoid this message.
2025-05-19T10:46:19.151+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: 2025-05-19 01:46:19,150 INFO Included extra file "/etc/supervisor/conf.d/app.conf" during parsing
2025-05-19T10:46:19.153+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: 2025-05-19 01:46:19,153 INFO RPC interface 'supervisor' initialized
2025-05-19T10:46:19.154+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: 2025-05-19 01:46:19,153 CRIT Server 'unix_http_server' running without any HTTP authentication checking
2025-05-19T10:46:19.154+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: 2025-05-19 01:46:19,153 INFO supervisord started with pid 1
2025-05-19T10:46:20.162+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: 2025-05-19 01:46:20,158 INFO spawned: 'maildev' with pid 7
2025-05-19T10:46:20.166+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: 2025-05-19 01:46:20,163 INFO spawned: 'sendgrid-dev' with pid 8
2025-05-19T10:46:20.174+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: SENDGRID_DEV_API_SERVER :3030
2025-05-19T10:46:20.175+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: SENDGRID_DEV_API_KEY SG.test
2025-05-19T10:46:20.176+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: SENDGRID_DEV_SMTP_SERVER 127.0.0.1:1025
2025-05-19T10:46:20.176+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: SENDGRID_DEV_SMTP_USERNAME
2025-05-19T10:46:20.176+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: SENDGRID_DEV_SMTP_PASSWORD
2025-05-19T10:46:20.176+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT:
2025-05-19T10:46:20.176+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: ____ __
2025-05-19T10:46:20.177+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: / __/___/ / ___
2025-05-19T10:46:20.177+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: / _// __/ _ \/ _ \
2025-05-19T10:46:20.177+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: /___/\__/_//_/\___/ v3.3.10-dev
2025-05-19T10:46:20.177+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: High performance, minimalist Go web framework
2025-05-19T10:46:20.178+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: https://echo.labstack.com
2025-05-19T10:46:20.178+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: ____________________________________O/_______
2025-05-19T10:46:20.178+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: O\
2025-05-19T10:46:20.179+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: ⇨ http server started on [::]:3030
2025-05-19T10:46:20.377+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: MailDev using directory /tmp/maildev-7
2025-05-19T10:46:20.385+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: MailDev webapp running at http://0.0.0.0:1080/
2025-05-19T10:46:20.385+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: MailDev SMTP Server running at 0.0.0.0:1025
2025-05-19T10:46:21.394+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: 2025-05-19 01:46:21,391 INFO success: maildev entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)
2025-05-19T10:46:21.394+09:00 INFO 57122 --- [demo-sendgrid] [ream--195085445] sendgrid-maildev : STDOUT: 2025-05-19 01:46:21,392 INFO success: sendgrid-dev entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)
2025-05-19T10:46:21.394+09:00 INFO 57122 --- [demo-sendgrid] [ main] tc.ykanazawa/sendgrid-maildev:latest : Container ykanazawa/sendgrid-maildev:latest started in PT2.452433S
2025-05-19T10:46:21.562+09:00 INFO 57122 --- [demo-sendgrid] [ main] o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint beneath base path '/actuator'
2025-05-19T10:46:21.577+09:00 INFO 57122 --- [demo-sendgrid] [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
2025-05-19T10:46:21.582+09:00 INFO 57122 --- [demo-sendgrid] [ main] com.example.DemoSendgridApplication : Started DemoSendgridApplication in 3.599 seconds (process running for 3.698)
2025-05-19T10:46:31.351+09:00 INFO 57122 --- [demo-sendgrid] [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
2025-05-19T10:46:31.351+09:00 INFO 57122 --- [demo-sendgrid] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2025-05-19T10:46:31.352+09:00 INFO 57122 --- [demo-sendgrid] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms
Open the MailDev Web UI at http://localhost:31080/.

Call the API to send an email.
$ curl -s http://localhost:8080/send --json '{"to":"makingx@example.com","subject":"Hello World!", "content": "This is a test mail!"}'
{"message":"Sent"}
Check in the MailDev Web UI to confirm the email was received.

Creating an Integration Test
Use the Testcontainers configuration as-is to create an integration test.
Since MailDev has a REST API that returns received emails, call the /email API to check the received emails.
However, this API does not support HTTP/2. The default ClientHttpRequestFactory for RestClient in Spring Boot 3.4 is set to use JdkClientHttpRequestFactory, which uses HTTP/2,
so specify spring.http.client.factory=simple to use the HTTP/2-unsupported SimpleClientHttpRequestFactory.
cat <<'EOF' > src/test/java/com/example/HelloControllerIntegrationTests.java
package com.example;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Import;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestClient;
import static org.assertj.core.api.Assertions.assertThat;
@Import(TestcontainersConfiguration.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = { "maildev.port=0", "spring.http.client.factory=simple" })
public class HelloControllerIntegrationTests {
RestClient restClient;
@LocalServerPort
int serverPort;
int maildevPort;
@BeforeEach
void setUp(@Autowired RestClient.Builder restClientBuilder, @Value("${maildev.port}") int maildevPort) {
this.restClient = restClientBuilder.defaultStatusHandler(__ -> true, (req, res) -> {
}).build();
this.maildevPort = maildevPort;
}
@Test
void testSend() {
ResponseEntity<String> response = this.restClient.post()
.uri("http://localhost:{port}/send", this.serverPort)
.contentType(MediaType.APPLICATION_JSON)
.body("""
{"to":"makingx@example.com","subject":"Hello World!", "content": "This is a test mail!"}
""")
.retrieve()
.toEntity(String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isEqualToIgnoringWhitespace("""
{"message":"Sent"}
""");
List<Map<String, Object>> received = this.restClient.get()
.uri("http://localhost:{port}/email", this.maildevPort)
.retrieve()
.body(new ParameterizedTypeReference<>() {
});
assertThat(received).hasSize(1);
assertThat(received.get(0)).containsEntry("subject", "Hello World!");
assertThat(received.get(0)).containsEntry("text", "This is a test mail!\n");
assertThat(received.get(0)).containsEntry("to", List.of(Map.of("address", "makingx@example.com", "name", "")));
assertThat(received.get(0)).containsEntry("from",
List.of(Map.of("address", "noreply@example.com", "name", "")));
}
}
EOF
Make sure the test passes.
./mvnw test
Switching to SendGrid
After confirming it works locally, change the destination to SendGrid.
Start the application with the following command, setting the SendGrid API key and sender email address as arguments.
./mvnw spring-boot:run -Dspring-boot.run.arguments="--sendgrid.api-key=YOUR_SENDGRID_API_KEY --sendgrid.from=YOUR_VERIFIED_SENDER_EMAIL"
Call the API to send an email.
$ curl -s http://localhost:8080/send --json '{"to":"YOUR_REAL_EMAIL","subject":"Hello World!", "content": "This is a test mail!"}'
{"message":"Sent"}
Check that the email has arrived.

I’ve written down how to call the SendGrid API directly with RestClient.
I also introduced how to use Testcontainers to start a SendGrid mock for testing.