Go 发送邮件(2026):net/http、errgroup 与 Webhook 验签
用 Go 标准库发送事务邮件:HTTP 请求、幂等重试、errgroup 并发批量发送、SMTP、文件附件,以及 HMAC-SHA256 Webhook 验签。

Go 非常适合做邮件自动化:标准库自带 HTTP 和 SMTP 所需的一切,goroutine 让并发分发不需要框架,类型系统让请求结构体保持严谨。本文覆盖通过 Rovela API 发送事务邮件、使用 Idempotency-Key 安全重试、用 errgroup 并发分发、添加附件以及验证 Webhook 签名——所有模式都可以直接用于生产环境。
前置条件
- Go 1.21 及以上
- Rovela 控制台里的 API Key
- 并发批量发送需要:
go get golang.org/x/sync/errgroup
定义请求类型
先定义与 Rovela /emails JSON 对应的结构体。
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"time"
)
const rovelaURL = "https://api.rovela.dev/emails"
type Attachment struct {
Filename string `json:"filename"`
Content string `json:"content"` // base64 编码后的文件内容
ContentType string `json:"content_type"`
}
type SendRequest struct {
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html,omitempty"`
Text string `json:"text,omitempty"`
ReplyTo string `json:"reply_to,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
}
type EmailResponse struct {
ID string `json:"id"`
}
type APIError struct {
StatusCode int `json:"statusCode"`
Name string `json:"name"`
Message string `json:"message"`
}
func (e *APIError) Error() string {
return fmt.Sprintf("rovela %d %s: %s", e.StatusCode, e.Name, e.Message)
}
发送单封邮件
核心函数构造 HTTP 请求、设置 Authorization 头、解码响应。传入非空的 idempotencyKey 可开启安全重试;单次发送传空字符串即可。
func sendEmail(
ctx context.Context,
client *http.Client,
apiKey string,
req SendRequest,
idempotencyKey string,
) (*EmailResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequestWithContext(
ctx, http.MethodPost, rovelaURL, bytes.NewReader(body),
)
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
httpReq.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
httpReq.Header.Set("Idempotency-Key", idempotencyKey)
}
resp, err := client.Do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
var apiErr APIError
json.NewDecoder(resp.Body).Decode(&apiErr)
apiErr.StatusCode = resp.StatusCode
return nil, &apiErr
}
var result EmailResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}
调用示例:
func main() {
apiKey := os.Getenv("ROVELA_API_KEY")
ctx := context.Background()
client := &http.Client{Timeout: 10 * time.Second}
result, err := sendEmail(ctx, client, apiKey, SendRequest{
From: "hello@yourcompany.com",
To: []string{"customer@example.com"},
Subject: "欢迎使用平台",
HTML: "<h1>您已成功注册。</h1><p>点击下方按钮开始使用。</p>",
}, "")
if err != nil {
fmt.Println("发送失败:", err)
return
}
fmt.Println("已发送:", result.ID)
}
带幂等键的重试
Rovela API 在 POST /emails 上接受 Idempotency-Key 请求头。24 小时窗口内,相同的 key 会返回缓存响应,而不会重复发送。在循环之前生成一次 key,确保每次重试使用的是同一个值。
func sendWithRetry(
ctx context.Context,
client *http.Client,
apiKey string,
req SendRequest,
maxAttempts int,
) (*EmailResponse, error) {
// 生成一次,每次重试复用,保证幂等
idempotencyKey := fmt.Sprintf("%s-%d", req.To[0], time.Now().UnixNano())
for attempt := 0; attempt < maxAttempts; attempt++ {
result, err := sendEmail(ctx, client, apiKey, req, idempotencyKey)
if err == nil {
return result, nil
}
// 只对 429 Too Many Requests 重试
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != 429 {
return nil, err
}
backoff := time.Duration(1<<uint(attempt)) * time.Second
select {
case <-time.After(backoff):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("超过最大重试次数 %d", maxAttempts)
}
用 errgroup 批量并发发送
golang.org/x/sync/errgroup 将 sync.WaitGroup 与错误传播和取消机制结合在一起。SetLimit 控制并发数量,对速率限制至关重要。
import "golang.org/x/sync/errgroup"
func sendBatch(ctx context.Context, apiKey string, reqs []SendRequest) error {
client := &http.Client{Timeout: 10 * time.Second}
g, gCtx := errgroup.WithContext(ctx)
g.SetLimit(5) // 最多同时 5 个并发发送
for i, req := range reqs {
i, req := i, req // 捕获循环变量
g.Go(func() error {
_, err := sendWithRetry(gCtx, client, apiKey, req, 3)
return err
})
}
return g.Wait()
}
任意 goroutine 返回错误时,gCtx 会被取消,g.Wait() 等所有 goroutine 结束后返回该错误。批量接口(POST /emails/batch)支持单次最多 100 个收件人,适合所有收件人共享同一正文的场景;每个收件人需要个性化内容或附件时,用上面的扇出模式更合适。
通过 SMTP 发送
如果需要直接驱动 SMTP——例如通过自定义域名中继发送——Go 的 crypto/tls 和 net/smtp 包可以无依赖地处理 TLS 握手。
import (
"crypto/tls"
"net/smtp"
)
func sendViaSMTP(smtpHost, username, password, from, to, subject, body string) error {
auth := smtp.PlainAuth("", username, password, smtpHost)
conn, err := tls.Dial("tcp", smtpHost+":465", &tls.Config{ServerName: smtpHost})
if err != nil {
return err
}
c, err := smtp.NewClient(conn, smtpHost)
if err != nil {
return err
}
defer c.Close()
if err = c.Auth(auth); err != nil {
return err
}
if err = c.Mail(from); err != nil {
return err
}
if err = c.Rcpt(to); err != nil {
return err
}
w, err := c.Data()
if err != nil {
return err
}
msg := fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\n\r\n%s",
from, to, subject, body,
)
if _, err = fmt.Fprint(w, msg); err != nil {
return err
}
return w.Close()
}
SMTP 主机和端口可以在 Rovela 控制台的 Domains → SMTP settings 里找到,凭据与 REST API 使用的 API Key 相同。
文件附件
在 SendRequest 里加入 Attachments 切片,对文件内容进行 base64 编码。附件不支持批量接口,需通过 POST /emails 逐封发送。
func buildInvoiceEmail(apiKey, recipient, invoicePath string) SendRequest {
data, err := os.ReadFile(invoicePath)
if err != nil {
panic(err)
}
return SendRequest{
From: "billing@yourcompany.com",
To: []string{recipient},
Subject: "您的发票",
HTML: "<p>请查收附件中的发票。</p>",
Attachments: []Attachment{
{
Filename: "invoice.pdf",
Content: base64.StdEncoding.EncodeToString(data),
ContentType: "application/pdf",
},
},
}
}
Webhook 签名验证
Rovela 用 HMAC-SHA256 对 Webhook 投递签名。签名在 x-resend-signature 头中,格式为 t=<时间戳>,v1=<十六进制签名>。签名原文是时间戳、一个英文句号、原始请求体拼接而成:<时间戳>.<原始请求体>。
签名比较使用 crypto/subtle.ConstantTimeCompare,避免普通字符串比较暴露的时序攻击。
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"io"
"net/http"
"strings"
)
// VerifyWebhook 读取请求体、验签,并返回原始 body 供调用方解码。
// 验签后会还原 r.Body,确保后续处理器仍可正常读取。
func VerifyWebhook(r *http.Request, secret string) ([]byte, error) {
sigHeader := r.Header.Get("x-resend-signature")
if sigHeader == "" {
return nil, fmt.Errorf("缺少 x-resend-signature 请求头")
}
var timestamp, v1 string
for _, part := range strings.Split(sigHeader, ",") {
switch {
case strings.HasPrefix(part, "t="):
timestamp = strings.TrimPrefix(part, "t=")
case strings.HasPrefix(part, "v1="):
v1 = strings.TrimPrefix(part, "v1=")
}
}
if timestamp == "" || v1 == "" {
return nil, fmt.Errorf("x-resend-signature 头格式错误")
}
rawBody, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
r.Body = io.NopCloser(bytes.NewReader(rawBody))
// 签名原文 = "<时间戳>.<原始请求体>"
signed := timestamp + "." + string(rawBody)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signed))
expected := mac.Sum(nil)
got, err := hex.DecodeString(v1)
if err != nil {
return nil, fmt.Errorf("签名十六进制解码失败: %w", err)
}
if subtle.ConstantTimeCompare(got, expected) != 1 {
return nil, fmt.Errorf("签名不匹配")
}
return rawBody, nil
}
接入 HTTP 处理器:
func webhookHandler(w http.ResponseWriter, r *http.Request) {
secret := os.Getenv("ROVELA_WEBHOOK_SECRET")
body, err := VerifyWebhook(r, secret)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var event map[string]any
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
fmt.Printf("收到事件: %s\n", event["type"])
w.WriteHeader(http.StatusNoContent)
}
上线前检查清单
- API Key 放在环境变量里:从
ROVELA_API_KEY读取,不要硬编码,不要提交到版本控制。 - 幂等键在循环外生成:在
for attempt循环之前生成一次,循环内复用同一个值。 - 传入 context 并设置超时:每次调用
sendEmail都传入带 deadline 的 context,否则一个挂住的 HTTP 连接会永久阻塞 goroutine。 - 处理速率限制:API 在超限时返回
429,body 为{"statusCode":429,"name":"Too Many Requests"};上面的重试循环已通过指数退避处理。 - 合理设置 errgroup 并发上限:
SetLimit(n)的值不要超过 Rovela 速率上限除以预期单封延迟的结果。 - Webhook 密钥存在环境变量里:在 Rovela 控制台和服务器部署里同步更换。
- 使用常量时间比较:签名字节必须用
subtle.ConstantTimeCompare,不能用==或bytes.Equal。