← 返回博客

Go 发送邮件(2026):net/http、errgroup 与 Webhook 验签

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

更新于 2026年11月3日
Go 发送邮件(2026):net/http、errgroup 与 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。