Skip to content

Commit 6137703

Browse files
authored
Add native Go Resend current parity (#114)
* Add native Go Resend current parity - Implements native Resend emails, domains, API keys, audiences, contacts, inbox UI, and explicit JSON seed handling in the Go runtime. - Adds official Resend SDK external E2E coverage plus CI wiring for the native Go service. - Updates Resend docs, skills, and service listings for native runtime support. * Reject unsupported native seed configs
1 parent 359da54 commit 6137703

18 files changed

Lines changed: 1501 additions & 22 deletions

File tree

.github/workflows/ci.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,22 @@ jobs:
5353
test "${ready:-}" = "1"
5454
AWS_EMULATOR_E2E_URL=http://127.0.0.1:4017 pnpm --dir packages/@emulators/aws exec vitest run src/__tests__/aws-sdk.e2e.test.ts -t "sqs|iam|sts"
5555
56+
- name: Native Resend SDK E2E
57+
run: |
58+
go build -o /tmp/emulate-native ./cmd/emulate
59+
/tmp/emulate-native start --service resend --port 4018 &
60+
server_pid=$!
61+
trap 'kill "$server_pid" 2>/dev/null || true' EXIT
62+
for attempt in {1..50}; do
63+
if curl -fsS http://127.0.0.1:4018/_emulate/health >/dev/null; then
64+
ready=1
65+
break
66+
fi
67+
sleep 0.2
68+
done
69+
test "${ready:-}" = "1"
70+
RESEND_EMULATOR_E2E_URL=http://127.0.0.1:4018 pnpm --dir packages/@emulators/resend exec vitest run src/__tests__/resend.test.ts -t "real resend SDK E2E"
71+
5672
- run: node scripts/sync-versions.mjs --check
5773

5874
- run: pnpm format:check

README.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@ All services start with sensible defaults. No config file needed:
1616
- **Slack** on `http://localhost:4003`
1717
- **Apple** on `http://localhost:4004`
1818
- **Microsoft** on `http://localhost:4005`
19-
- **AWS** on `http://localhost:4006`
19+
- **Okta** on `http://localhost:4006`
20+
- **AWS** on `http://localhost:4007`
21+
- **Resend** on `http://localhost:4008`
22+
- **Stripe** on `http://localhost:4009`
23+
- **MongoDB Atlas** on `http://localhost:4010`
24+
- **Clerk** on `http://localhost:4011`
2025

2126
## CLI
2227

@@ -141,7 +146,7 @@ afterAll(() => Promise.all([github.close(), vercel.close()]))
141146

142147
| Option | Default | Description |
143148
|--------|---------|-------------|
144-
| `service` | *(required)* | Service name: `'vercel'`, `'github'`, `'google'`, `'slack'`, `'apple'`, `'microsoft'`, or `'aws'` |
149+
| `service` | *(required)* | Service name: `'vercel'`, `'github'`, `'google'`, `'slack'`, `'apple'`, `'microsoft'`, `'okta'`, `'aws'`, `'resend'`, `'stripe'`, `'mongoatlas'`, or `'clerk'` |
145150
| `port` | `4000` | Port for the HTTP server |
146151
| `seed` | none | Inline seed data (same shape as YAML config) |
147152
| `baseUrl` | none | Override advertised base URL. Per-service `baseUrl` in seed config takes highest priority, then this option, then `EMULATE_BASE_URL` env var (supports `{service}`), then `PORTLESS_URL` (supports `{service}`, automatically set by the `portless` CLI wrapper), then `http://localhost:<port>`. |
@@ -693,6 +698,19 @@ Manual STS requests can use `POST /sts/` with an `Action` form parameter. In the
693698

694699
- `GetCallerIdentity`, `AssumeRole`
695700

701+
## Resend
702+
703+
Resend email API emulation with local capture for sent emails, domains, API keys, audiences, contacts, and an inbox UI. Set `RESEND_BASE_URL` before importing the official Resend Node.js SDK and the SDK will send to the emulator.
704+
705+
The experimental native Go runtime serves the same current Resend routes, supports explicit JSON seed configs for Resend through `--seed`, and is verified against the official `resend` SDK for emails, batch email sends, domains, API keys, and legacy audience contacts.
706+
707+
- `POST /emails`, `POST /emails/batch`, `GET /emails`, `GET /emails/:id`, `POST /emails/:id/cancel`
708+
- `POST /domains`, `GET /domains`, `GET /domains/:id`, `DELETE /domains/:id`, `POST /domains/:id/verify`
709+
- `POST /api-keys`, `GET /api-keys`, `DELETE /api-keys/:id`
710+
- `POST /audiences`, `GET /audiences`, `DELETE /audiences/:id`
711+
- `POST /audiences/:audience_id/contacts`, `GET /audiences/:audience_id/contacts`, `DELETE /audiences/:audience_id/contacts/:id`
712+
- `GET /inbox`, `GET /inbox/:id`
713+
696714
## Next.js Integration
697715

698716
Embed emulators directly in your Next.js app so they run on the same origin. This solves the Vercel preview deployment problem where OAuth callback URLs change with every deployment.

apps/web/app/docs/page.mdx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@ All services start with sensible defaults. No config file needed:
1616
- **Slack** on `http://localhost:4003`
1717
- **Apple** on `http://localhost:4004`
1818
- **Microsoft** on `http://localhost:4005`
19-
- **AWS** on `http://localhost:4006`
20-
- **Okta** on `http://localhost:4007`
21-
- **MongoDB Atlas** on `http://localhost:4008`
22-
- **Resend** on `http://localhost:4009`
23-
- **Stripe** on `http://localhost:4010`
19+
- **Okta** on `http://localhost:4006`
20+
- **AWS** on `http://localhost:4007`
21+
- **Resend** on `http://localhost:4008`
22+
- **Stripe** on `http://localhost:4009`
23+
- **MongoDB Atlas** on `http://localhost:4010`
24+
- **Clerk** on `http://localhost:4011`
2425

2526
## CLI
2627

apps/web/app/docs/programmatic-api/page.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ afterAll(() => Promise.all([github.close(), vercel.close()]))
5959
<tr>
6060
<td><code>service</code></td>
6161
<td><em>(required)</em></td>
62-
<td>Service name: <code>'vercel'</code>, <code>'github'</code>, <code>'google'</code>, <code>'slack'</code>, <code>'apple'</code>, <code>'microsoft'</code>, <code>'aws'</code>, <code>'okta'</code>, <code>'mongoatlas'</code>, <code>'resend'</code>, or <code>'stripe'</code></td>
62+
<td>Service name: <code>'vercel'</code>, <code>'github'</code>, <code>'google'</code>, <code>'slack'</code>, <code>'apple'</code>, <code>'microsoft'</code>, <code>'okta'</code>, <code>'aws'</code>, <code>'resend'</code>, <code>'stripe'</code>, <code>'mongoatlas'</code>, or <code>'clerk'</code></td>
6363
</tr>
6464
<tr>
6565
<td><code>port</code></td>

apps/web/app/docs/resend/page.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
Resend email API emulation with email sending, domain management, API keys, audiences, contacts, and a local inbox for captured messages.
44

5+
Set `RESEND_BASE_URL` before importing the official `resend` Node.js SDK and the SDK will call the emulator without code changes.
6+
7+
The experimental native Go runtime implements the current Resend routes below, supports explicit JSON seed configs for Resend through `--seed`, and is verified against the official `resend` SDK for emails, batch email sends, domains, API keys, and legacy audience contacts.
8+
59
## Emails
610

711
- `POST /emails` — send single email

cmd/emulate/main.go

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"flag"
78
"fmt"
@@ -19,6 +20,7 @@ import (
1920

2021
coreconfig "github.com/vercel-labs/emulate/internal/core/config"
2122
emuruntime "github.com/vercel-labs/emulate/internal/runtime"
23+
"github.com/vercel-labs/emulate/internal/services/resend"
2224
)
2325

2426
var version = "dev"
@@ -100,24 +102,46 @@ func runStart(ctx context.Context, args []string, stdout io.Writer, stderr io.Wr
100102
fmt.Fprintln(stderr, "The native Go runtime does not support --portless yet.")
101103
return 1
102104
}
105+
var seedServices []string
106+
var resendSeed *resend.SeedConfig
103107
if *seedValue != "" {
104-
fmt.Fprintln(stderr, "The native Go runtime does not support --seed yet.")
105-
return 1
108+
loaded, err := coreconfig.Load(coreconfig.LoadOptions{Path: *seedValue})
109+
if err != nil {
110+
fmt.Fprintf(stderr, "Failed to load seed config: %v\n", err)
111+
return 1
112+
}
113+
if unsupported := unsupportedNativeSeedServices(loaded.Data); len(unsupported) > 0 {
114+
fmt.Fprintf(stderr, "The native Go runtime only supports --seed for resend. Unsupported seed config services: %s\n", strings.Join(unsupported, ", "))
115+
return 1
116+
}
117+
seedServices = coreconfig.InferServices(loaded.Data, nativeSeedServiceNames())
118+
if raw, ok := loaded.Data["resend"]; ok {
119+
var cfg resend.SeedConfig
120+
if err := json.Unmarshal(raw, &cfg); err != nil {
121+
fmt.Fprintf(stderr, "Failed to parse resend seed config: %v\n", err)
122+
return 1
123+
}
124+
resendSeed = &cfg
125+
}
106126
}
107127
services, err := parseServices(*serviceValue)
108128
if err != nil {
109129
fmt.Fprintln(stderr, err)
110130
return 1
111131
}
132+
if *serviceValue == "" && len(seedServices) > 0 {
133+
services = seedServices
134+
}
112135

113136
baseURL := *baseURLValue
114137
if baseURL == "" {
115138
baseURL = fmt.Sprintf("http://localhost:%d", port)
116139
}
117140
server := emuruntime.NewServer(emuruntime.ServerOptions{
118-
Version: version,
119-
BaseURL: baseURL,
120-
Services: services,
141+
Version: version,
142+
BaseURL: baseURL,
143+
Services: services,
144+
ResendSeed: resendSeed,
121145
})
122146
httpServer := &nethttp.Server{
123147
Handler: server.Handler,
@@ -252,7 +276,7 @@ func printHelp(w io.Writer) {
252276
fmt.Fprintln(w, "\nStart options:")
253277
fmt.Fprintln(w, " -p, --port <port> Base port")
254278
fmt.Fprintln(w, " -s, --service <services> Comma-separated services to enable")
255-
fmt.Fprintln(w, " --seed <file> Path to seed config file (not supported in native Go yet)")
279+
fmt.Fprintln(w, " --seed <file> Path to JSON seed config file (YAML not supported in native Go yet)")
256280
fmt.Fprintln(w, " --base-url <url> Override advertised base URL")
257281
fmt.Fprintln(w, " --portless Serve over HTTPS via portless (not supported in native Go yet)")
258282
fmt.Fprintln(w, "\nThe published TypeScript CLI remains the default user-facing runtime.")
@@ -265,7 +289,7 @@ func printStartHelp(w io.Writer) {
265289
fmt.Fprintln(w, "\nOptions:")
266290
fmt.Fprintln(w, " -p, --port <port> Base port")
267291
fmt.Fprintln(w, " -s, --service <services> Comma-separated services to enable")
268-
fmt.Fprintln(w, " --seed <file> Path to seed config file (not supported in native Go yet)")
292+
fmt.Fprintln(w, " --seed <file> Path to JSON seed config file (YAML not supported in native Go yet)")
269293
fmt.Fprintln(w, " --base-url <url> Override advertised base URL")
270294
fmt.Fprintln(w, " --portless Serve over HTTPS via portless (not supported in native Go yet)")
271295
}
@@ -305,6 +329,28 @@ func parseServices(value string) ([]string, error) {
305329
return services, nil
306330
}
307331

332+
func nativeSeedServiceNames() []string {
333+
return []string{"resend"}
334+
}
335+
336+
func unsupportedNativeSeedServices(data map[string]json.RawMessage) []string {
337+
supported := map[string]bool{}
338+
for _, service := range nativeSeedServiceNames() {
339+
supported[service] = true
340+
}
341+
342+
unsupported := make([]string, 0)
343+
for _, service := range emuruntime.ServiceNames() {
344+
if supported[service] {
345+
continue
346+
}
347+
if _, ok := data[service]; ok {
348+
unsupported = append(unsupported, service)
349+
}
350+
}
351+
return unsupported
352+
}
353+
308354
func getenv(name string, fallback string) string {
309355
if value := os.Getenv(name); value != "" {
310356
return value

cmd/emulate/main_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,97 @@ func TestRunStartServesHealthEndpoint(t *testing.T) {
158158
}
159159
}
160160

161+
func TestRunStartSeedsResendFromJSONConfig(t *testing.T) {
162+
tempDir := t.TempDir()
163+
seedPath := filepath.Join(tempDir, "emulate.config.json")
164+
if err := os.WriteFile(seedPath, []byte(`{"resend":{"domains":[{"name":"example.com"}],"contacts":[{"email":"test@example.com","first_name":"Test"}]}}`), 0o644); err != nil {
165+
t.Fatal(err)
166+
}
167+
168+
port := freePort(t)
169+
ctx, cancel := context.WithCancel(context.Background())
170+
defer cancel()
171+
172+
var stdout, stderr bytes.Buffer
173+
done := make(chan int, 1)
174+
go func() {
175+
done <- runWithContext(ctx, []string{"start", "--port", strconv.Itoa(port), "--seed", seedPath}, &stdout, &stderr)
176+
}()
177+
178+
healthURL := fmt.Sprintf("http://127.0.0.1:%d%s", port, emuruntime.HealthPath)
179+
var health struct {
180+
OK bool `json:"ok"`
181+
Services []string `json:"services"`
182+
}
183+
waitForJSON(t, healthURL, &health)
184+
if !health.OK || len(health.Services) != 1 || health.Services[0] != "resend" {
185+
t.Fatalf("unexpected health body: %#v", health)
186+
}
187+
188+
var domains struct {
189+
Data []struct {
190+
Name string `json:"name"`
191+
Status string `json:"status"`
192+
} `json:"data"`
193+
}
194+
waitForJSON(t, fmt.Sprintf("http://127.0.0.1:%d/domains", port), &domains)
195+
if len(domains.Data) != 1 || domains.Data[0].Name != "example.com" || domains.Data[0].Status != "verified" {
196+
t.Fatalf("unexpected seeded domains: %#v", domains)
197+
}
198+
199+
cancel()
200+
select {
201+
case code := <-done:
202+
if code != 0 {
203+
t.Fatalf("start exited with %d, stderr: %s", code, stderr.String())
204+
}
205+
case <-time.After(2 * time.Second):
206+
t.Fatal("start did not shut down after context cancellation")
207+
}
208+
}
209+
210+
func TestRunStartRejectsYAMLSeedConfig(t *testing.T) {
211+
tempDir := t.TempDir()
212+
seedPath := filepath.Join(tempDir, "emulate.config.yaml")
213+
if err := os.WriteFile(seedPath, []byte("resend:\n domains:\n - name: example.com\n"), 0o644); err != nil {
214+
t.Fatal(err)
215+
}
216+
217+
var stdout, stderr bytes.Buffer
218+
code := run([]string{"start", "--seed", seedPath}, &stdout, &stderr)
219+
if code == 0 {
220+
t.Fatal("start with YAML seed exited successfully")
221+
}
222+
if !strings.Contains(stderr.String(), "YAML config loading is not implemented") {
223+
t.Fatalf("unexpected stderr: %s", stderr.String())
224+
}
225+
}
226+
227+
func TestRunStartRejectsUnsupportedNativeSeedServices(t *testing.T) {
228+
tempDir := t.TempDir()
229+
seedPath := filepath.Join(tempDir, "emulate.config.json")
230+
if err := os.WriteFile(seedPath, []byte(`{"aws":{"s3":{"buckets":[{"name":"example"}]}}}`), 0o644); err != nil {
231+
t.Fatal(err)
232+
}
233+
234+
for _, args := range [][]string{
235+
{"start", "--seed", seedPath},
236+
{"start", "--service", "aws", "--seed", seedPath},
237+
} {
238+
t.Run(strings.Join(args[:2], " "), func(t *testing.T) {
239+
var stdout, stderr bytes.Buffer
240+
code := run(args, &stdout, &stderr)
241+
if code == 0 {
242+
t.Fatal("start with unsupported seed service exited successfully")
243+
}
244+
errText := stderr.String()
245+
if !strings.Contains(errText, "only supports --seed for resend") || !strings.Contains(errText, "aws") {
246+
t.Fatalf("unexpected stderr: %s", errText)
247+
}
248+
})
249+
}
250+
}
251+
161252
func TestRunTopLevelHelpIncludesFullStartOptions(t *testing.T) {
162253
var stdout, stderr bytes.Buffer
163254
code := run([]string{"--help"}, &stdout, &stderr)

internal/core/ui/ui.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,9 +292,13 @@ body{font-family:'Geist',-apple-system,BlinkMacSystemFont,sans-serif;background:
292292
.inspector-table th,.inspector-table td{text-align:left;padding:8px 12px;border-bottom:1px solid #0a3300;font-size:.8125rem;}
293293
.inspector-table th{color:#1a8c00;font-weight:600;font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;}
294294
.inspector-table td{color:#33ff00;}
295+
.inspector-table a{color:#33ff00;text-decoration:none;}
296+
.inspector-table a:hover{text-decoration:underline;}
295297
.inspector-table tbody tr{transition:background .1s;}
296298
.inspector-table tbody tr:hover{background:#0a3300;}
297299
.inspector-empty{color:#1a8c00;text-align:center;padding:20px 0;font-size:.8125rem;}
300+
.email-preview-frame{width:100%;min-height:300px;border:1px solid #0a3300;border-radius:8px;background:#fff;}
301+
.email-preview-text{white-space:pre-wrap;word-break:break-word;color:#33ff00;font:inherit;}
298302
`
299303
}
300304

internal/runtime/server.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,17 @@ import (
77
"github.com/vercel-labs/emulate/internal/core/store"
88
"github.com/vercel-labs/emulate/internal/core/ui"
99
"github.com/vercel-labs/emulate/internal/services/aws"
10+
"github.com/vercel-labs/emulate/internal/services/resend"
1011
)
1112

1213
const HealthPath = "/_emulate/health"
1314

1415
type ServerOptions struct {
15-
Version string
16-
BaseURL string
17-
Services []string
18-
Store *store.Store
16+
Version string
17+
BaseURL string
18+
Services []string
19+
Store *store.Store
20+
ResendSeed *resend.SeedConfig
1921
}
2022

2123
type Server struct {
@@ -69,6 +71,12 @@ func NewServer(options ServerOptions) *Server {
6971
BaseURL: options.BaseURL,
7072
})
7173
}
74+
if serviceEnabled(services, "resend") {
75+
resend.Register(router, resend.Options{
76+
Store: runtimeStore,
77+
Seed: options.ResendSeed,
78+
})
79+
}
7280
router.NotFound(func(c *corehttp.Context) {
7381
c.JSON(http.StatusNotFound, map[string]any{"message": "Not Found"})
7482
})

internal/runtime/server_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,33 @@ func TestNewHandlerDoesNotMountAWSWhenDisabled(t *testing.T) {
167167
t.Fatalf("status = %d, body = %s", res.Code, res.Body.String())
168168
}
169169
}
170+
171+
func TestNewHandlerMountsResendWhenEnabled(t *testing.T) {
172+
handler := NewHandler(ServerOptions{Services: []string{"resend"}})
173+
174+
res := httptest.NewRecorder()
175+
req := httptest.NewRequest(http.MethodPost, "/emails", strings.NewReader(`{"from":"a@example.com","to":"b@example.com","subject":"Hello"}`))
176+
req.Header.Set("Content-Type", "application/json")
177+
req.Header.Set("Authorization", "Bearer re_test_token")
178+
handler.ServeHTTP(res, req)
179+
180+
if res.Code != http.StatusOK {
181+
t.Fatalf("status = %d, body = %s", res.Code, res.Body.String())
182+
}
183+
if !strings.Contains(res.Body.String(), `"id"`) {
184+
t.Fatalf("unexpected body: %s", res.Body.String())
185+
}
186+
}
187+
188+
func TestNewHandlerDoesNotMountResendWhenDisabled(t *testing.T) {
189+
handler := NewHandler(ServerOptions{Services: []string{"github"}})
190+
191+
res := httptest.NewRecorder()
192+
req := httptest.NewRequest(http.MethodPost, "/emails", strings.NewReader(`{"from":"a@example.com","to":"b@example.com","subject":"Hello"}`))
193+
req.Header.Set("Content-Type", "application/json")
194+
handler.ServeHTTP(res, req)
195+
196+
if res.Code != http.StatusNotFound {
197+
t.Fatalf("status = %d, body = %s", res.Code, res.Body.String())
198+
}
199+
}

0 commit comments

Comments
 (0)