123 lines
3.3 KiB
Go
123 lines
3.3 KiB
Go
package email
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
type resendTransport struct {
|
||
|
|
apiKey string
|
||
|
|
client *http.Client
|
||
|
|
}
|
||
|
|
|
||
|
|
func newResendTransport(apiKey string, client *http.Client) *resendTransport {
|
||
|
|
if client == nil {
|
||
|
|
client = &http.Client{Timeout: 20 * time.Second}
|
||
|
|
}
|
||
|
|
return &resendTransport{apiKey: apiKey, client: client}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *resendTransport) Name() string { return ProviderResend }
|
||
|
|
|
||
|
|
func (r *resendTransport) Send(ctx context.Context, from FromIdentity, msg Outbound) error {
|
||
|
|
to := strings.TrimSpace(msg.To)
|
||
|
|
if to == "" {
|
||
|
|
return ErrInvalidRecipient
|
||
|
|
}
|
||
|
|
payload := map[string]any{
|
||
|
|
"from": from.Formatted(),
|
||
|
|
"to": []string{to},
|
||
|
|
"subject": msg.Subject,
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(msg.HTML) != "" {
|
||
|
|
payload["html"] = msg.HTML
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(msg.Text) != "" {
|
||
|
|
payload["text"] = msg.Text
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(from.ReplyTo) != "" {
|
||
|
|
payload["reply_to"] = from.ReplyTo
|
||
|
|
}
|
||
|
|
if len(msg.Headers) > 0 {
|
||
|
|
payload["headers"] = msg.Headers
|
||
|
|
}
|
||
|
|
body, err := json.Marshal(payload)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body))
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
req.Header.Set("Authorization", "Bearer "+r.apiKey)
|
||
|
|
req.Header.Set("Content-Type", "application/json")
|
||
|
|
res, err := r.client.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("resend request failed")
|
||
|
|
}
|
||
|
|
defer res.Body.Close()
|
||
|
|
raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||
|
|
if res.StatusCode >= 300 {
|
||
|
|
return fmt.Errorf("resend api %d", res.StatusCode)
|
||
|
|
}
|
||
|
|
_ = raw
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// verifyResendDomain checks the API key can list domains and that domain appears verified.
|
||
|
|
// When Resend returns no domains (sandbox), returns ok=false with a clear message.
|
||
|
|
func verifyResendDomain(ctx context.Context, apiKey, domain string, client *http.Client) (bool, string, error) {
|
||
|
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
||
|
|
if domain == "" {
|
||
|
|
return false, "domain required", nil
|
||
|
|
}
|
||
|
|
if client == nil {
|
||
|
|
client = &http.Client{Timeout: 15 * time.Second}
|
||
|
|
}
|
||
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.resend.com/domains", nil)
|
||
|
|
if err != nil {
|
||
|
|
return false, "", err
|
||
|
|
}
|
||
|
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||
|
|
res, err := client.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return false, "", fmt.Errorf("resend domains request failed")
|
||
|
|
}
|
||
|
|
defer res.Body.Close()
|
||
|
|
raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||
|
|
if err != nil {
|
||
|
|
return false, "", err
|
||
|
|
}
|
||
|
|
if res.StatusCode == http.StatusUnauthorized {
|
||
|
|
return false, "invalid Resend API key", nil
|
||
|
|
}
|
||
|
|
if res.StatusCode >= 300 {
|
||
|
|
return false, fmt.Sprintf("resend domains api %d", res.StatusCode), nil
|
||
|
|
}
|
||
|
|
var parsed struct {
|
||
|
|
Data []struct {
|
||
|
|
Name string `json:"name"`
|
||
|
|
Status string `json:"status"`
|
||
|
|
} `json:"data"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||
|
|
return false, "unexpected resend response", nil
|
||
|
|
}
|
||
|
|
for _, d := range parsed.Data {
|
||
|
|
if strings.EqualFold(d.Name, domain) {
|
||
|
|
st := strings.ToLower(strings.TrimSpace(d.Status))
|
||
|
|
if st == "verified" || st == "ok" || st == "active" {
|
||
|
|
return true, "domain verified with Resend", nil
|
||
|
|
}
|
||
|
|
return false, fmt.Sprintf("Resend domain status is %q", d.Status), nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false, "domain not found in Resend account — add and verify it in the Resend dashboard", nil
|
||
|
|
}
|