Go · 2425 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package email
4
5 import (
6 "bytes"
7 "context"
8 "encoding/json"
9 "fmt"
10 "io"
11 "net/http"
12 "time"
13 )
14
15 // ResendSender posts messages to the Resend transactional API
16 // (https://resend.com). Same shape as PostmarkSender — one HTTP POST per
17 // message, no templating, no batching. Resend's value vs. Postmark is
18 // near-instant onboarding (no human approval queue), which is why we
19 // keep both implementations selectable per-deploy.
20 type ResendSender struct {
21 APIKey string // Bearer token; ops creates this in the Resend dashboard
22 From string // verified sender address; domain must be verified in Resend
23 Endpoint string // optional override; defaults to resendAPI for tests
24 HTTP *http.Client
25 }
26
27 // resendAPI is the canonical endpoint. Hard-coded — Resend is SaaS.
28 const resendAPI = "https://api.resend.com/emails"
29
30 // resendPayload is the request body shape. The field names match
31 // Resend's documented JSON keys (lowercase singular).
32 type resendPayload struct {
33 From string `json:"from"`
34 To string `json:"to"`
35 Subject string `json:"subject"`
36 HTML string `json:"html"`
37 Text string `json:"text"`
38 }
39
40 // Send implements Sender.
41 func (r *ResendSender) Send(ctx context.Context, m Message) error {
42 if m.From == "" {
43 m.From = r.From
44 }
45 body, err := json.Marshal(resendPayload(m))
46 if err != nil {
47 return fmt.Errorf("resend: marshal: %w", err)
48 }
49
50 endpoint := r.Endpoint
51 if endpoint == "" {
52 endpoint = resendAPI
53 }
54 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
55 if err != nil {
56 return fmt.Errorf("resend: request: %w", err)
57 }
58 req.Header.Set("Accept", "application/json")
59 req.Header.Set("Content-Type", "application/json")
60 req.Header.Set("Authorization", "Bearer "+r.APIKey)
61
62 client := r.HTTP
63 if client == nil {
64 client = &http.Client{Timeout: 10 * time.Second}
65 }
66 resp, err := client.Do(req)
67 if err != nil {
68 return fmt.Errorf("resend: send: %w", err)
69 }
70 defer func() { _ = resp.Body.Close() }()
71
72 if resp.StatusCode/100 != 2 {
73 // Resend returns a JSON error body like {"name":"...", "message":"..."}.
74 // Surface a snippet so operators can debug bad keys / unverified
75 // domains without re-running with curl.
76 snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
77 return fmt.Errorf("resend: status %d: %s", resp.StatusCode, bytes.TrimSpace(snippet))
78 }
79 return nil
80 }
81