← 返回博客
用 Java 通过 Rovela API 发送邮件
介绍如何用 Java 的 HttpClient、OkHttp 和 Spring Boot RestClient 发送事务邮件,包含幂等键重试、Semaphore 批量发送,以及 Spring Boot 和 Jakarta Servlet 的 Webhook 验签。
更新于 2026年11月3日

本文介绍如何用 Java 调用 Rovela API 发送事务邮件,涵盖 Java 11+ 内置 HttpClient、OkHttp、Spring Boot RestClient,以及 Spring Boot 和 Jakarta Servlet 的 Webhook 验签。
前提条件
将 API 密钥设置为环境变量:
export ROVELA_API_KEY="re_..."
用 Java HttpClient(Java 11+)发送
无需额外依赖,使用标准库即可。
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
public class RovelaExample {
private static final String API_URL = "https://api.rovela.dev/emails";
public static void main(String[] args) throws Exception {
String apiKey = System.getenv("ROVELA_API_KEY");
String body = """
{
"from": "Acme <noreply@yourdomain.com>",
"to": "user@example.com",
"subject": "欢迎加入",
"html": "<p>感谢注册!</p>"
}
""";
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(API_URL))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
System.out.println(response.body());
}
}
用 OkHttp 发送
OkHttp 是 JVM 生态中广泛使用的 HTTP 客户端。
import okhttp3.*;
import java.io.IOException;
public class RovelaOkHttp {
private static final MediaType JSON = MediaType.get("application/json");
private static final String API_URL = "https://api.rovela.dev/emails";
public static void main(String[] args) throws IOException {
String apiKey = System.getenv("ROVELA_API_KEY");
OkHttpClient client = new OkHttpClient();
String bodyJson = """
{
"from": "Acme <noreply@yourdomain.com>",
"to": "user@example.com",
"subject": "欢迎加入",
"html": "<p>感谢注册!</p>"
}
""";
Request request = new Request.Builder()
.url(API_URL)
.addHeader("Authorization", "Bearer " + apiKey)
.post(RequestBody.create(bodyJson, JSON))
.build();
try (Response response = client.newCall(request).execute()) {
System.out.println(response.code());
System.out.println(response.body().string());
}
}
}
用 Spring Boot RestClient 发送
RestClient 是 Spring 6 / Spring Boot 3.2+ 推荐的同步 HTTP 客户端。
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.http.MediaType;
import java.util.Map;
@Service
public class EmailService {
private final RestClient restClient;
private final String apiKey = System.getenv("ROVELA_API_KEY");
public EmailService() {
this.restClient = RestClient.builder()
.baseUrl("https://api.rovela.dev")
.defaultHeader("Authorization", "Bearer " + apiKey)
.build();
}
public String sendEmail(String to, String subject, String html) {
Map<String, String> payload = Map.of(
"from", "Acme <noreply@yourdomain.com>",
"to", to, "subject", subject, "html", html
);
return restClient.post()
.uri("/emails")
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.body(String.class);
}
}
重试与幂等键
在循环之前生成一次幂等键,每次重试都复用同一个键,防止网络中断后重复发送。
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
public class RetryExample {
private static final String API_URL = "https://api.rovela.dev/emails";
private static final int MAX_RETRIES = 4;
public static HttpResponse<String> sendWithRetry(String payload) throws Exception {
String idempotencyKey = UUID.randomUUID().toString(); // 循环前生成,重试时复用
String apiKey = System.getenv("ROVELA_API_KEY");
HttpClient client = HttpClient.newHttpClient();
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(API_URL))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.header("Idempotency-Key", idempotencyKey)
.POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 429) return response;
long delay = (long) (1000 * Math.pow(2, attempt));
String retryAfter = response.headers().firstValue("Retry-After").orElse(null);
if (retryAfter != null) delay = Long.parseLong(retryAfter) * 1000L;
Thread.sleep(delay);
}
throw new RuntimeException("超过最大重试次数 " + MAX_RETRIES);
}
}
批量发送
用 Semaphore 限制并发,对大量收件人并行发送:
import java.util.concurrent.*;
public class BatchSender {
private static final int CONCURRENCY = 5;
record Recipient(String email, String subject, String html) {}
public static void sendBatch(List<Recipient> recipients) throws Exception {
String apiKey = System.getenv("ROVELA_API_KEY");
HttpClient client = HttpClient.newHttpClient();
Semaphore semaphore = new Semaphore(CONCURRENCY);
List<CompletableFuture<Void>> futures = new ArrayList<>();
for (Recipient r : recipients) {
semaphore.acquire();
futures.add(CompletableFuture.runAsync(() -> {
try {
// 构造请求,见上文 HttpClient 示例
System.out.println(r.email() + " 发送成功");
} catch (Exception e) {
System.err.println(r.email() + " 失败: " + e.getMessage());
} finally {
semaphore.release();
}
}));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
}
Spring Boot Webhook 验签
Rovela 用 HMAC-SHA256 对每个事件签名。签名在 x-resend-signature 请求头中,格式为 t=<时间戳>,v1=<十六进制签名>。用 Webhook 密钥对 <时间戳>.<原始 body> 签名,再用 MessageDigest.isEqual 常量时间比对。
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
public class WebhookController {
private final String webhookSecret = System.getenv("ROVELA_WEBHOOK_SECRET");
@PostMapping("/webhooks/rovela")
public ResponseEntity<String> handle(HttpServletRequest request) throws Exception {
byte[] rawBody = request.getInputStream().readAllBytes();
String sigHeader = request.getHeader("x-resend-signature");
if (sigHeader == null) return ResponseEntity.badRequest().body("missing signature");
Map<String, String> parts = Arrays.stream(sigHeader.split(","))
.map(s -> s.split("=", 2))
.filter(a -> a.length == 2)
.collect(Collectors.toMap(a -> a[0], a -> a[1]));
String timestamp = parts.get("t");
String v1 = parts.get("v1");
if (timestamp == null || v1 == null)
return ResponseEntity.badRequest().body("invalid signature format");
String signedString = timestamp + "." + new String(rawBody, StandardCharsets.UTF_8);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(webhookSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = HexFormat.of().formatHex(
mac.doFinal(signedString.getBytes(StandardCharsets.UTF_8)));
if (!MessageDigest.isEqual(expected.getBytes(), v1.getBytes()))
return ResponseEntity.badRequest().body("invalid signature");
return ResponseEntity.ok("ok");
}
}
Jakarta Servlet 验签
不使用 Spring 时,直接用 Servlet:
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Map;
import java.util.stream.Collectors;
@WebServlet("/webhooks/rovela")
public class RovelaWebhookServlet extends HttpServlet {
private final String webhookSecret = System.getenv("ROVELA_WEBHOOK_SECRET");
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse res)
throws IOException, ServletException {
byte[] rawBody = req.getInputStream().readAllBytes();
String sigHeader = req.getHeader("x-resend-signature");
if (sigHeader == null) { res.sendError(400, "missing signature"); return; }
Map<String, String> parts = java.util.Arrays.stream(sigHeader.split(","))
.map(s -> s.split("=", 2))
.filter(a -> a.length == 2)
.collect(Collectors.toMap(a -> a[0], a -> a[1]));
String timestamp = parts.get("t");
String v1 = parts.get("v1");
if (timestamp == null || v1 == null) { res.sendError(400, "invalid format"); return; }
try {
String signedString = timestamp + "." + new String(rawBody, StandardCharsets.UTF_8);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(webhookSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = HexFormat.of().formatHex(mac.doFinal(signedString.getBytes(StandardCharsets.UTF_8)));
if (!MessageDigest.isEqual(expected.getBytes(), v1.getBytes())) {
res.sendError(400, "invalid signature");
return;
}
res.setStatus(200);
res.getWriter().write("ok");
} catch (Exception e) {
res.sendError(500, e.getMessage());
}
}
}