← 返回博客

用 Java 通过 Rovela API 发送邮件

介绍如何用 Java 的 HttpClient、OkHttp 和 Spring Boot RestClient 发送事务邮件,包含幂等键重试、Semaphore 批量发送,以及 Spring Boot 和 Jakarta Servlet 的 Webhook 验签。

更新于 2026年11月3日
用 Java 通过 Rovela API 发送邮件

本文介绍如何用 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());
        }
    }
}