tenseleyflow/shithub / f171069

Browse files

email: add Resend transactional backend

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
f1710690ff66a791e4b087887cdb5ec50403ae97
Parents
8fdc95c
Tree
28ced98

2 changed files

StatusFile+-
A internal/auth/email/resend.go 80 0
A internal/auth/email/resend_test.go 110 0
internal/auth/email/resend.goadded
@@ -0,0 +1,80 @@
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
+}
internal/auth/email/resend_test.goadded
@@ -0,0 +1,110 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package email
4
+
5
+import (
6
+	"context"
7
+	"encoding/json"
8
+	"io"
9
+	"net/http"
10
+	"net/http/httptest"
11
+	"strings"
12
+	"testing"
13
+)
14
+
15
+func TestResendSender_Send_SuccessShapesRequest(t *testing.T) {
16
+	t.Parallel()
17
+	var (
18
+		gotMethod string
19
+		gotPath   string
20
+		gotAuth   string
21
+		gotCT     string
22
+		gotBody   resendPayload
23
+	)
24
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25
+		gotMethod = r.Method
26
+		gotPath = r.URL.Path
27
+		gotAuth = r.Header.Get("Authorization")
28
+		gotCT = r.Header.Get("Content-Type")
29
+		raw, _ := io.ReadAll(r.Body)
30
+		_ = json.Unmarshal(raw, &gotBody)
31
+		w.WriteHeader(http.StatusOK)
32
+		_, _ = w.Write([]byte(`{"id":"00000000-0000-0000-0000-000000000000"}`))
33
+	}))
34
+	defer srv.Close()
35
+
36
+	s := &ResendSender{
37
+		APIKey:   "re_test_secret",
38
+		From:     "noreply@shithub.sh",
39
+		Endpoint: srv.URL,
40
+	}
41
+	err := s.Send(context.Background(), Message{
42
+		To: "alice@example.com", Subject: "hi", HTML: "<b>hi</b>", Text: "hi",
43
+	})
44
+	if err != nil {
45
+		t.Fatalf("Send: %v", err)
46
+	}
47
+	if gotMethod != http.MethodPost {
48
+		t.Errorf("method = %q, want POST", gotMethod)
49
+	}
50
+	if gotPath != "/" {
51
+		t.Errorf("path = %q, want /", gotPath)
52
+	}
53
+	if gotAuth != "Bearer re_test_secret" {
54
+		t.Errorf("Authorization = %q, want Bearer re_test_secret", gotAuth)
55
+	}
56
+	if gotCT != "application/json" {
57
+		t.Errorf("Content-Type = %q, want application/json", gotCT)
58
+	}
59
+	if gotBody.From != "noreply@shithub.sh" {
60
+		t.Errorf("body.From = %q, want default From", gotBody.From)
61
+	}
62
+	if gotBody.To != "alice@example.com" || gotBody.Subject != "hi" ||
63
+		gotBody.HTML != "<b>hi</b>" || gotBody.Text != "hi" {
64
+		t.Errorf("body fields wrong: %+v", gotBody)
65
+	}
66
+}
67
+
68
+func TestResendSender_Send_PerMessageFromOverridesDefault(t *testing.T) {
69
+	t.Parallel()
70
+	var gotFrom string
71
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
72
+		var p resendPayload
73
+		raw, _ := io.ReadAll(r.Body)
74
+		_ = json.Unmarshal(raw, &p)
75
+		gotFrom = p.From
76
+		w.WriteHeader(http.StatusOK)
77
+	}))
78
+	defer srv.Close()
79
+
80
+	s := &ResendSender{APIKey: "k", From: "default@x", Endpoint: srv.URL}
81
+	if err := s.Send(context.Background(), Message{
82
+		From: "override@x", To: "a@x", Subject: "s", HTML: "H", Text: "T",
83
+	}); err != nil {
84
+		t.Fatalf("Send: %v", err)
85
+	}
86
+	if gotFrom != "override@x" {
87
+		t.Errorf("From = %q, want override@x", gotFrom)
88
+	}
89
+}
90
+
91
+func TestResendSender_Send_PropagatesAPIError(t *testing.T) {
92
+	t.Parallel()
93
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
94
+		w.WriteHeader(http.StatusUnauthorized)
95
+		_, _ = w.Write([]byte(`{"name":"missing_api_key","message":"API key is missing"}`))
96
+	}))
97
+	defer srv.Close()
98
+
99
+	s := &ResendSender{APIKey: "bad", From: "noreply@x", Endpoint: srv.URL}
100
+	err := s.Send(context.Background(), Message{To: "a@x", Subject: "s", HTML: "H", Text: "T"})
101
+	if err == nil {
102
+		t.Fatal("expected error on 401, got nil")
103
+	}
104
+	if !strings.Contains(err.Error(), "401") {
105
+		t.Errorf("error missing status code: %v", err)
106
+	}
107
+	if !strings.Contains(err.Error(), "missing_api_key") {
108
+		t.Errorf("error missing API body snippet: %v", err)
109
+	}
110
+}