chore: import clean Agent Desk snapshot (2/7)

This commit is contained in:
2026-10-16 11:57:21 +00:00
parent 79176541c7
commit 89c5231c4c
461 changed files with 60092 additions and 0 deletions

494
internal/email/client.go Normal file
View File

@@ -0,0 +1,494 @@
package email
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/mail"
"net/smtp"
"net/url"
"strconv"
"strings"
"time"
)
const (
defaultBrevoBaseURL = "https://api.brevo.com/v3"
defaultSendGridBaseURL = "https://api.sendgrid.com/v3"
defaultResendBaseURL = "https://api.resend.com"
defaultPostmarkBaseURL = "https://api.postmarkapp.com"
defaultMailgunBaseURL = "https://api.mailgun.net/v3"
defaultTimeout = 20 * time.Second
)
// Client interface for sending emails across multiple providers.
type Client interface {
SendEmail(ctx context.Context, req SendEmailParams) error
}
type emailClient struct {
cfg ClientConfig
httpClient *http.Client
}
// NewClient creates a new unified Email client.
func NewClient(cfg ClientConfig) Client {
provider := DeliveryProvider(strings.ToLower(strings.TrimSpace(string(cfg.Provider))))
if provider == "" {
if cfg.APIKey != "" {
if strings.HasPrefix(cfg.APIKey, "xkeysib-") {
provider = ProviderBrevo
} else if strings.HasPrefix(cfg.APIKey, "SG.") {
provider = ProviderSendGrid
} else if strings.HasPrefix(cfg.APIKey, "re_") {
provider = ProviderResend
} else {
provider = ProviderBrevo
}
} else {
provider = ProviderSMTP
}
}
cfg.Provider = provider
if cfg.SMTPPort <= 0 {
cfg.SMTPPort = 587
}
return &emailClient{
cfg: cfg,
httpClient: &http.Client{Timeout: defaultTimeout},
}
}
func (c *emailClient) SendEmail(ctx context.Context, req SendEmailParams) error {
req.FromEmail = strings.TrimSpace(req.FromEmail)
req.ToEmail = strings.TrimSpace(req.ToEmail)
if req.FromEmail == "" || req.ToEmail == "" {
return fmt.Errorf("fromEmail and toEmail are required")
}
if req.Subject == "" {
req.Subject = "Support Notification"
}
switch c.cfg.Provider {
case ProviderBrevo:
return c.sendViaBrevo(ctx, req)
case ProviderSendGrid:
return c.sendViaSendGrid(ctx, req)
case ProviderResend:
return c.sendViaResend(ctx, req)
case ProviderPostmark:
return c.sendViaPostmark(ctx, req)
case ProviderMailgun:
return c.sendViaMailgun(ctx, req)
default:
return c.sendViaSMTP(ctx, req)
}
}
// 1. Brevo v3 API
func (c *emailClient) sendViaBrevo(ctx context.Context, req SendEmailParams) error {
baseURL := strings.TrimRight(c.cfg.BaseURL, "/")
if baseURL == "" {
baseURL = defaultBrevoBaseURL
}
apiURL := fmt.Sprintf("%s/smtp/email", baseURL)
senderName := req.FromName
if senderName == "" {
senderName = "Customer Support"
}
payload := BrevoSendEmailRequest{
Sender: BrevoEmailContact{
Name: senderName,
Email: req.FromEmail,
},
To: []BrevoEmailContact{
{
Name: req.ToName,
Email: req.ToEmail,
},
},
Subject: req.Subject,
TextContent: req.BodyText,
HTMLContent: req.BodyHTML,
Headers: req.Headers,
}
if req.ReplyTo != "" {
payload.ReplyTo = &BrevoEmailContact{Email: req.ReplyTo}
}
if payload.HTMLContent == "" && payload.TextContent != "" {
payload.HTMLContent = formatHTMLParagraphs(payload.TextContent)
}
return c.postJSON(ctx, apiURL, payload, map[string]string{
"api-key": c.cfg.APIKey,
})
}
// 2. SendGrid v3 API
func (c *emailClient) sendViaSendGrid(ctx context.Context, req SendEmailParams) error {
baseURL := strings.TrimRight(c.cfg.BaseURL, "/")
if baseURL == "" {
baseURL = defaultSendGridBaseURL
}
apiURL := fmt.Sprintf("%s/mail/send", baseURL)
payload := SendGridSendEmailRequest{
Personalizations: []SendGridPersonalization{
{
To: []SendGridContact{{Email: req.ToEmail, Name: req.ToName}},
},
},
From: SendGridContact{Email: req.FromEmail, Name: req.FromName},
Subject: req.Subject,
Headers: req.Headers,
}
if req.ReplyTo != "" {
payload.ReplyTo = &SendGridContact{Email: req.ReplyTo}
}
if req.BodyText != "" {
payload.Content = append(payload.Content, SendGridContent{Type: "text/plain", Value: req.BodyText})
}
if req.BodyHTML != "" {
payload.Content = append(payload.Content, SendGridContent{Type: "text/html", Value: req.BodyHTML})
} else if req.BodyText != "" {
payload.Content = append(payload.Content, SendGridContent{Type: "text/html", Value: formatHTMLParagraphs(req.BodyText)})
}
return c.postJSON(ctx, apiURL, payload, map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", c.cfg.APIKey),
})
}
// 3. Resend API
func (c *emailClient) sendViaResend(ctx context.Context, req SendEmailParams) error {
baseURL := strings.TrimRight(c.cfg.BaseURL, "/")
if baseURL == "" {
baseURL = defaultResendBaseURL
}
apiURL := fmt.Sprintf("%s/emails", baseURL)
fromHeader := req.FromEmail
if req.FromName != "" {
fromHeader = fmt.Sprintf("%s <%s>", req.FromName, req.FromEmail)
}
payload := ResendSendEmailRequest{
From: fromHeader,
To: []string{req.ToEmail},
ReplyTo: req.ReplyTo,
Subject: req.Subject,
Text: req.BodyText,
HTML: req.BodyHTML,
Headers: req.Headers,
}
if payload.HTML == "" && payload.Text != "" {
payload.HTML = formatHTMLParagraphs(payload.Text)
}
return c.postJSON(ctx, apiURL, payload, map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", c.cfg.APIKey),
})
}
// 4. Postmark API
func (c *emailClient) sendViaPostmark(ctx context.Context, req SendEmailParams) error {
baseURL := strings.TrimRight(c.cfg.BaseURL, "/")
if baseURL == "" {
baseURL = defaultPostmarkBaseURL
}
apiURL := fmt.Sprintf("%s/email", baseURL)
fromHeader := req.FromEmail
if req.FromName != "" {
fromHeader = fmt.Sprintf("%s <%s>", req.FromName, req.FromEmail)
}
var headers []PostmarkHeader
for k, v := range req.Headers {
headers = append(headers, PostmarkHeader{Name: k, Value: v})
}
payload := PostmarkSendEmailRequest{
From: fromHeader,
To: req.ToEmail,
ReplyTo: req.ReplyTo,
Subject: req.Subject,
TextBody: req.BodyText,
HtmlBody: req.BodyHTML,
Headers: headers,
}
if payload.HtmlBody == "" && payload.TextBody != "" {
payload.HtmlBody = formatHTMLParagraphs(payload.TextBody)
}
return c.postJSON(ctx, apiURL, payload, map[string]string{
"X-Postmark-Server-Token": c.cfg.APIKey,
})
}
// 5. Mailgun Messages API
func (c *emailClient) sendViaMailgun(ctx context.Context, req SendEmailParams) error {
baseURL := strings.TrimRight(c.cfg.BaseURL, "/")
if baseURL == "" {
baseURL = defaultMailgunBaseURL
}
domain := c.cfg.Domain
if domain == "" {
parts := strings.Split(req.FromEmail, "@")
if len(parts) == 2 {
domain = parts[1]
}
}
apiURL := fmt.Sprintf("%s/%s/messages", baseURL, domain)
fromHeader := req.FromEmail
if req.FromName != "" {
fromHeader = fmt.Sprintf("%s <%s>", req.FromName, req.FromEmail)
}
form := url.Values{}
form.Set("from", fromHeader)
form.Set("to", req.ToEmail)
form.Set("subject", req.Subject)
if req.BodyText != "" {
form.Set("text", req.BodyText)
}
if req.BodyHTML != "" {
form.Set("html", req.BodyHTML)
} else if req.BodyText != "" {
form.Set("html", formatHTMLParagraphs(req.BodyText))
}
if req.ReplyTo != "" {
form.Set("h:Reply-To", req.ReplyTo)
}
if req.InReplyTo != "" {
form.Set("h:In-Reply-To", req.InReplyTo)
}
if req.References != "" {
form.Set("h:References", req.References)
}
for k, v := range req.Headers {
form.Set(fmt.Sprintf("h:%s", k), v)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(form.Encode()))
if err != nil {
return fmt.Errorf("create mailgun request failed: %w", err)
}
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
httpReq.SetBasicAuth("api", c.cfg.APIKey)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("mailgun request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("mailgun api error (status %d): %s", resp.StatusCode, string(body))
}
slog.Info("email sent via mailgun", "to", req.ToEmail, "subject", req.Subject)
return nil
}
// 6. Standard SMTP (AWS SES, Postmark SMTP, SendGrid SMTP, Brevo SMTP, custom Postfix)
func (c *emailClient) sendViaSMTP(ctx context.Context, req SendEmailParams) error {
if c.cfg.SMTPHost == "" {
return fmt.Errorf("smtp host is not configured")
}
addr := net.JoinHostPort(c.cfg.SMTPHost, strconv.Itoa(c.cfg.SMTPPort))
fromHeader := req.FromEmail
if req.FromName != "" {
fromHeader = fmt.Sprintf("%s <%s>", req.FromName, req.FromEmail)
}
header := make(map[string]string)
header["From"] = fromHeader
header["To"] = req.ToEmail
header["Subject"] = req.Subject
header["MIME-Version"] = "1.0"
if req.ReplyTo != "" {
header["Reply-To"] = req.ReplyTo
}
if req.MessageID != "" {
header["Message-ID"] = req.MessageID
}
if req.InReplyTo != "" {
header["In-Reply-To"] = req.InReplyTo
}
if req.References != "" {
header["References"] = req.References
}
for k, v := range req.Headers {
header[k] = v
}
contentType := "text/plain; charset=UTF-8"
body := req.BodyText
if req.BodyHTML != "" {
contentType = "text/html; charset=UTF-8"
body = req.BodyHTML
}
header["Content-Type"] = contentType
var msg bytes.Buffer
for k, v := range header {
msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
}
msg.WriteString("\r\n")
msg.WriteString(body)
var auth smtp.Auth
if c.cfg.SMTPUser != "" && c.cfg.SMTPPassword != "" {
auth = smtp.PlainAuth("", c.cfg.SMTPUser, c.cfg.SMTPPassword, c.cfg.SMTPHost)
}
tlsConfig := &tls.Config{
ServerName: c.cfg.SMTPHost,
}
if c.cfg.SMTPAllowInsecure {
// Test-server escape hatch. Verification is skipped rather than STARTTLS
// being declined outright, so a self-signed local server still gets an
// encrypted hop; only the trust check is waived.
tlsConfig.InsecureSkipVerify = true
}
var client *smtp.Client
var err error
tlsEstablished := false
if c.cfg.SMTPPort == 465 || c.cfg.SMTPUseTLS {
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: defaultTimeout}, "tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("failed to connect via tls: %w", err)
}
client, err = smtp.NewClient(conn, c.cfg.SMTPHost)
if err != nil {
return fmt.Errorf("failed to create smtp client: %w", err)
}
tlsEstablished = true
} else {
conn, err := net.DialTimeout("tcp", addr, defaultTimeout)
if err != nil {
return fmt.Errorf("failed to dial smtp: %w", err)
}
client, err = smtp.NewClient(conn, c.cfg.SMTPHost)
if err != nil {
return fmt.Errorf("failed to create smtp client: %w", err)
}
if ok, _ := client.Extension("STARTTLS"); ok {
// Opportunistic upgrade. A test server that advertises STARTTLS with a
// self-signed certificate would fail verification here and take the
// whole send down, so the binding can opt out of the upgrade instead.
if c.cfg.SMTPAllowInsecure {
if err = client.StartTLS(&tls.Config{ServerName: c.cfg.SMTPHost, InsecureSkipVerify: true}); err != nil {
return fmt.Errorf("failed to starttls: %w", err)
}
} else if err = client.StartTLS(tlsConfig); err != nil {
return fmt.Errorf("failed to starttls: %w", err)
}
tlsEstablished = true
}
}
defer client.Quit()
if auth != nil {
if ok, _ := client.Extension("AUTH"); ok {
// net/smtp PlainAuth refuses to send credentials over an unencrypted
// connection; fail early with an actionable message instead.
if !tlsEstablished && !isLocalhostSMTPHost(c.cfg.SMTPHost) {
return fmt.Errorf("refusing SMTP auth over an unencrypted connection: enable STARTTLS on the server, set SMTP_USE_TLS=true, or use port 465")
}
if err = client.Auth(auth); err != nil {
return fmt.Errorf("smtp auth failed: %w", err)
}
}
}
if err = client.Mail(req.FromEmail); err != nil {
return fmt.Errorf("smtp mail from failed: %w", err)
}
if err = client.Rcpt(req.ToEmail); err != nil {
return fmt.Errorf("smtp rcpt to failed: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("smtp data command failed: %w", err)
}
_, err = w.Write(msg.Bytes())
if err != nil {
return fmt.Errorf("failed to write email body: %w", err)
}
err = w.Close()
if err != nil {
return fmt.Errorf("failed to close email writer: %w", err)
}
slog.Info("email sent via smtp", "to", req.ToEmail, "subject", req.Subject)
return nil
}
func (c *emailClient) postJSON(ctx context.Context, apiURL string, payload any, headers map[string]string) error {
bodyBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal json payload: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(bodyBytes))
if err != nil {
return fmt.Errorf("failed to create http request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
for k, v := range headers {
httpReq.Header.Set(k, v)
}
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("email api request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("email api error (status %d): %s", resp.StatusCode, string(respBody))
}
return nil
}
func formatHTMLParagraphs(text string) string {
escaped := strings.ReplaceAll(text, "\n", "<br/>")
return fmt.Sprintf("<div style=\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.6; color: #333;\">%s</div>", escaped)
}
// ParseAddress parses a raw email address string like "Support Team <help@example.com>" into email and display name.
func ParseAddress(raw string) (emailStr string, nameStr string) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", ""
}
parsed, err := mail.ParseAddress(raw)
if err == nil && parsed != nil {
return strings.ToLower(strings.TrimSpace(parsed.Address)), strings.TrimSpace(parsed.Name)
}
return strings.ToLower(raw), ""
}
func isLocalhostSMTPHost(host string) bool {
h := strings.ToLower(strings.TrimSpace(host))
if i := strings.LastIndex(h, ":"); i >= 0 && !strings.Contains(h, "]") {
h = h[:i]
}
return h == "localhost" || h == "127.0.0.1" || h == "::1"
}

View File

@@ -0,0 +1,172 @@
package email
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
func TestParseAddress(t *testing.T) {
tests := []struct {
input string
wantEmail string
wantName string
}{
{"John Doe <john@example.com>", "john@example.com", "John Doe"},
{"<support@example.com>", "support@example.com", ""},
{"plain@example.com", "plain@example.com", ""},
{" Alice Smith <ALICE@DOMAIN.COM> ", "alice@domain.com", "Alice Smith"},
{"", "", ""},
}
for _, tt := range tests {
gotEmail, gotName := ParseAddress(tt.input)
if gotEmail != tt.wantEmail || gotName != tt.wantName {
t.Errorf("ParseAddress(%q) = (%q, %q), want (%q, %q)", tt.input, gotEmail, gotName, tt.wantEmail, tt.wantName)
}
}
}
func TestParseInboundWebhook_Formats(t *testing.T) {
// 1. Generic / Cloudflare format
genericJSON := []byte(`{
"from": "user@example.com",
"from_name": "Test User",
"to": "help@example.com",
"subject": "Hello Support",
"text": "Please help with login.",
"message_id": "<msg-001@example.com>"
}`)
items, err := ParseInboundWebhook("application/json", genericJSON, nil)
if err != nil {
t.Fatalf("ParseInboundWebhook generic failed: %v", err)
}
if len(items) != 1 || items[0].FromEmail != "user@example.com" || items[0].ToEmail != "help@example.com" {
t.Errorf("unexpected generic parsed output: %+v", items)
}
// 2. Brevo format
brevoJSON := []byte(`{
"items": [
{
"Sender": "Alice <alice@test.com>",
"Recipient": "support@example.com",
"Subject": "Brevo Inquiry",
"RawTextBody": "Brevo message text"
}
]
}`)
items, err = ParseInboundWebhook("application/json", brevoJSON, nil)
if err != nil {
t.Fatalf("ParseInboundWebhook brevo failed: %v", err)
}
if len(items) != 1 || items[0].FromEmail != "alice@test.com" || items[0].FromName != "Alice" {
t.Errorf("unexpected brevo parsed output: %+v", items)
}
// 3. Postmark format
postmarkJSON := []byte(`{
"From": "bob@domain.org",
"FromName": "Bob Developer",
"To": "help@example.com",
"Subject": "Postmark Question",
"TextBody": "Text from Postmark",
"MessageID": "pm-12345",
"Headers": [
{"Name": "In-Reply-To", "Value": "<parent-msg@example.com>"}
]
}`)
items, err = ParseInboundWebhook("application/json", postmarkJSON, nil)
if err != nil {
t.Fatalf("ParseInboundWebhook postmark failed: %v", err)
}
if len(items) != 1 || items[0].FromEmail != "bob@domain.org" || items[0].InReplyTo != "<parent-msg@example.com>" {
t.Errorf("unexpected postmark parsed output: %+v", items)
}
// 4. Mailgun form data
mgForm := url.Values{}
mgForm.Set("sender", "developer@client.com")
mgForm.Set("from", "Dev <developer@client.com>")
mgForm.Set("recipient", "help@example.com")
mgForm.Set("subject", "Mailgun Support")
mgForm.Set("body-plain", "Plain body from mailgun")
mgForm.Set("In-Reply-To", "<msg-999@example.com>")
items, err = ParseInboundWebhook("application/x-www-form-urlencoded", nil, mgForm)
if err != nil {
t.Fatalf("ParseInboundWebhook mailgun failed: %v", err)
}
if len(items) != 1 || items[0].FromEmail != "developer@client.com" || items[0].InReplyTo != "<msg-999@example.com>" {
t.Errorf("unexpected mailgun parsed output: %+v", items)
}
}
func TestSendGridSendEmail(t *testing.T) {
var receivedAuth string
var receivedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedAuth = r.Header.Get("Authorization")
receivedPath = r.URL.Path
w.WriteHeader(http.StatusAccepted)
}))
defer server.Close()
client := NewClient(ClientConfig{
Provider: ProviderSendGrid,
APIKey: "SG.test-key",
BaseURL: server.URL,
})
err := client.SendEmail(context.Background(), SendEmailParams{
FromEmail: "support@example.com",
ToEmail: "user@example.com",
Subject: "SendGrid Test",
BodyText: "Hello via SendGrid",
})
if err != nil {
t.Fatalf("SendEmail SendGrid failed: %v", err)
}
if receivedAuth != "Bearer SG.test-key" {
t.Errorf("expected Bearer SG.test-key, got %s", receivedAuth)
}
if receivedPath != "/mail/send" {
t.Errorf("expected /mail/send, got %s", receivedPath)
}
}
func TestResendSendEmail(t *testing.T) {
var receivedAuth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":"resend-123"}`))
}))
defer server.Close()
client := NewClient(ClientConfig{
Provider: ProviderResend,
APIKey: "re_test_123",
BaseURL: server.URL,
})
err := client.SendEmail(context.Background(), SendEmailParams{
FromEmail: "support@example.com",
ToEmail: "user@example.com",
Subject: "Resend Test",
BodyText: "Hello via Resend",
})
if err != nil {
t.Fatalf("SendEmail Resend failed: %v", err)
}
if receivedAuth != "Bearer re_test_123" {
t.Errorf("expected Bearer re_test_123, got %s", receivedAuth)
}
}

View File

@@ -0,0 +1,258 @@
package email
import (
"encoding/json"
"fmt"
"net/url"
"strings"
)
// UniversalInboundWebhook captures all possible field names across providers.
type UniversalInboundWebhook struct {
// From fields
From string `json:"from"`
FromCaps string `json:"From"`
Sender string `json:"sender"`
FromName string `json:"from_name"`
// To fields
To string `json:"to"`
ToCaps string `json:"To"`
Recipient string `json:"recipient"`
ToName string `json:"to_name"`
// Subject
Subject string `json:"subject"`
SubjectCaps string `json:"Subject"`
// Body fields
Text string `json:"text"`
Body string `json:"body"`
BodyPlain string `json:"body-plain"`
TextBody string `json:"TextBody"`
RawTextBody string `json:"RawTextBody"`
HTML string `json:"html"`
BodyHTML string `json:"body-html"`
HtmlBody string `json:"HtmlBody"`
RawHtmlBody string `json:"RawHtmlBody"`
// Message IDs & Threading
MessageID string `json:"message_id"`
MessageIDCaps string `json:"MessageID"`
InReplyTo string `json:"in_reply_to"`
References string `json:"references"`
MailboxHash string `json:"MailboxHash"`
// Headers can be a map or an array of objects
Headers any `json:"headers"`
HeadersCaps any `json:"Headers"`
// Brevo items array
Items []BrevoInboundItem `json:"items"`
}
// ParseInboundWebhook parses raw webhook payload from various email providers into a slice of normalized InboundEmailPayloads.
func ParseInboundWebhook(contentType string, rawBody []byte, form url.Values) ([]InboundEmailPayload, error) {
contentType = strings.ToLower(contentType)
// 1. Form-data (Mailgun, SendGrid Inbound Parse)
if len(form) > 0 {
fromRaw := form.Get("from")
if fromRaw == "" {
fromRaw = form.Get("sender")
}
fromEmail, fromName := ParseAddress(fromRaw)
toRaw := form.Get("to")
if toRaw == "" {
toRaw = form.Get("recipient")
}
if toRaw == "" {
toRaw = form.Get("To")
}
toEmail, toName := ParseAddress(toRaw)
bodyText := form.Get("body-plain")
if bodyText == "" {
bodyText = form.Get("stripped-text")
}
if bodyText == "" {
bodyText = form.Get("text")
}
bodyHTML := form.Get("body-html")
if bodyHTML == "" {
bodyHTML = form.Get("stripped-html")
}
if bodyHTML == "" {
bodyHTML = form.Get("html")
}
return []InboundEmailPayload{
{
FromEmail: fromEmail,
FromName: fromName,
ToEmail: toEmail,
ToName: toName,
Subject: strings.TrimSpace(form.Get("subject")),
BodyText: strings.TrimSpace(bodyText),
BodyHTML: strings.TrimSpace(bodyHTML),
MessageID: strings.TrimSpace(form.Get("Message-Id")),
InReplyTo: strings.TrimSpace(form.Get("In-Reply-To")),
References: strings.TrimSpace(form.Get("References")),
},
}, nil
}
rawStr := strings.TrimSpace(string(rawBody))
if rawStr == "" {
return nil, nil
}
// 2. Parse JSON
var u UniversalInboundWebhook
if err := json.Unmarshal(rawBody, &u); err != nil {
return nil, fmt.Errorf("unmarshal email json failed: %w", err)
}
// Check Brevo items format
if len(u.Items) > 0 {
var results []InboundEmailPayload
for _, item := range u.Items {
fromEmail, fromName := ParseAddress(item.Sender)
toEmail, toName := ParseAddress(item.Recipient)
msgID := ""
if len(item.UUID) > 0 {
msgID = item.UUID[0]
}
results = append(results, InboundEmailPayload{
FromEmail: fromEmail,
FromName: fromName,
ToEmail: toEmail,
ToName: toName,
Subject: strings.TrimSpace(item.Subject),
BodyText: strings.TrimSpace(item.RawTextBody),
BodyHTML: strings.TrimSpace(item.RawHTMLBody),
MessageID: msgID,
Headers: item.Headers,
})
}
return results, nil
}
// Resolve From
fromRaw := firstNonEmpty(u.From, u.FromCaps, u.Sender)
fromEmail, fromName := ParseAddress(fromRaw)
if u.FromName != "" {
fromName = u.FromName
}
// Resolve To
toRaw := firstNonEmpty(u.To, u.ToCaps, u.Recipient)
toEmail, toName := ParseAddress(toRaw)
if u.ToName != "" {
toName = u.ToName
}
// Resolve Subject
subject := firstNonEmpty(u.Subject, u.SubjectCaps)
// Resolve Body Text
bodyText := firstNonEmpty(u.Text, u.Body, u.BodyPlain, u.TextBody, u.RawTextBody)
// Resolve Body HTML
bodyHTML := firstNonEmpty(u.HTML, u.BodyHTML, u.HtmlBody, u.RawHtmlBody)
// Resolve Message ID
messageID := firstNonEmpty(u.MessageID, u.MessageIDCaps)
// Extract headers & In-Reply-To / References
headersMap, inReplyTo, references := extractHeadersAndThreading(u.Headers, u.HeadersCaps, u.InReplyTo, u.References)
if fromEmail == "" && toEmail == "" && subject == "" && bodyText == "" && bodyHTML == "" {
return nil, fmt.Errorf("unrecognized email webhook format")
}
return []InboundEmailPayload{
{
FromEmail: fromEmail,
FromName: fromName,
ToEmail: toEmail,
ToName: toName,
Subject: strings.TrimSpace(subject),
BodyText: strings.TrimSpace(bodyText),
BodyHTML: strings.TrimSpace(bodyHTML),
MessageID: strings.TrimSpace(messageID),
InReplyTo: strings.TrimSpace(inReplyTo),
References: strings.TrimSpace(references),
Headers: headersMap,
},
}, nil
}
func extractHeadersAndThreading(headers1, headers2 any, fallbackInReply, fallbackRef string) (map[string]string, string, string) {
headersMap := make(map[string]string)
inReplyTo := fallbackInReply
references := fallbackRef
for _, h := range []any{headers1, headers2} {
if h == nil {
continue
}
switch val := h.(type) {
case map[string]any:
for k, v := range val {
s := fmt.Sprintf("%v", v)
headersMap[k] = s
if strings.EqualFold(k, "In-Reply-To") && inReplyTo == "" {
inReplyTo = s
}
if strings.EqualFold(k, "References") && references == "" {
references = s
}
}
case map[string]string:
for k, v := range val {
headersMap[k] = v
if strings.EqualFold(k, "In-Reply-To") && inReplyTo == "" {
inReplyTo = v
}
if strings.EqualFold(k, "References") && references == "" {
references = v
}
}
case []any:
for _, item := range val {
if itemMap, ok := item.(map[string]any); ok {
name := fmt.Sprintf("%v", itemMap["Name"])
if name == "" {
name = fmt.Sprintf("%v", itemMap["name"])
}
value := fmt.Sprintf("%v", itemMap["Value"])
if value == "" {
value = fmt.Sprintf("%v", itemMap["value"])
}
if name != "" {
headersMap[name] = value
if strings.EqualFold(name, "In-Reply-To") && inReplyTo == "" {
inReplyTo = value
}
if strings.EqualFold(name, "References") && references == "" {
references = value
}
}
}
}
}
}
return headersMap, inReplyTo, references
}
func firstNonEmpty(strs ...string) string {
for _, s := range strs {
if trimmed := strings.TrimSpace(s); trimmed != "" {
return trimmed
}
}
return ""
}

149
internal/email/probe.go Normal file
View File

@@ -0,0 +1,149 @@
package email
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/smtp"
"sort"
"strconv"
"strings"
"time"
)
// SMTPProbeResult describes what a bound SMTP server actually offers, so an
// operator can tell "wrong host" from "reachable but will not accept our
// credentials" from "needs TLS and we are not using it".
type SMTPProbeResult struct {
Host string `json:"host"`
Port int `json:"port"`
Provider string `json:"provider"`
Greeting string `json:"greeting"`
Capabilities []string `json:"capabilities"`
STARTTLSOffered bool `json:"starttlsOffered"`
TLSEstablished bool `json:"tlsEstablished"`
AuthAccepted bool `json:"authAccepted"`
// AuthAttempted is false when the binding carries no credentials, which is
// the normal shape of a local test server.
AuthAttempted bool `json:"authAttempted"`
}
// ProbeSMTP opens a connection to the configured server, negotiates the same
// transport the sender would use, and reports what it found. It sends no mail.
//
// The probe deliberately reuses the sender's transport rules rather than a
// friendlier variant: a probe that succeeds by being more permissive than the
// real send would let a binding verify as healthy while every message fails.
func ProbeSMTP(ctx context.Context, cfg ClientConfig) (*SMTPProbeResult, error) {
if strings.TrimSpace(cfg.SMTPHost) == "" {
return nil, fmt.Errorf("smtp host is not configured")
}
port := cfg.SMTPPort
if port <= 0 {
port = 587
}
addr := net.JoinHostPort(cfg.SMTPHost, strconv.Itoa(port))
timeout := 15 * time.Second
if deadline, ok := ctx.Deadline(); ok {
if remaining := time.Until(deadline); remaining > 0 && remaining < timeout {
timeout = remaining
}
}
dialer := &net.Dialer{Timeout: timeout}
result := &SMTPProbeResult{
Host: cfg.SMTPHost,
Port: port,
Provider: string(cfg.Provider),
}
var (
conn net.Conn
client *smtp.Client
tlsConfigured bool
err error
)
if port == 465 || cfg.SMTPUseTLS {
tlsConfigured = true
tlsConfig := &tls.Config{ServerName: cfg.SMTPHost}
if cfg.SMTPAllowInsecure {
tlsConfig.InsecureSkipVerify = true
}
conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
if err != nil {
return result, fmt.Errorf("failed to connect via tls: %w", err)
}
result.TLSEstablished = true
} else {
conn, err = dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return result, fmt.Errorf("failed to dial smtp: %w", err)
}
}
client, err = smtp.NewClient(conn, cfg.SMTPHost)
if err != nil {
_ = conn.Close()
return result, fmt.Errorf("failed to create smtp client: %w", err)
}
defer func() {
_ = client.Quit()
}()
// net/smtp exposes no extension lister, so the capabilities that actually
// affect delivery are probed by name. A raw list would be noise to an operator.
if err = client.Hello("localhost"); err != nil {
_ = conn.Close()
return result, fmt.Errorf("smtp greeting failed: %w", err)
}
if ext, _ := client.Extension("STARTTLS"); ext {
result.STARTTLSOffered = true
if !tlsConfigured && !cfg.SMTPAllowInsecure {
tlsConfig := &tls.Config{ServerName: cfg.SMTPHost}
if err = client.StartTLS(tlsConfig); err != nil {
return result, fmt.Errorf("starttls failed (enable allow-insecure for a self-signed test server): %w", err)
}
result.TLSEstablished = true
}
}
capabilities := make([]string, 0, 6)
for _, ext := range []string{"8BITMIME", "SMTPUTF8", "PIPELINING", "SIZE", "ENHANCEDSTATUSCODES", "AUTH PLAIN"} {
if ok, _ := client.Extension(ext); ok {
capabilities = append(capabilities, strings.ToUpper(ext))
}
}
sort.Strings(capabilities)
result.Capabilities = capabilities
if strings.TrimSpace(cfg.SMTPUser) != "" && strings.TrimSpace(cfg.SMTPPassword) != "" {
result.AuthAttempted = true
if ok, _ := client.Extension("AUTH"); ok {
auth := smtp.PlainAuth("", cfg.SMTPUser, cfg.SMTPPassword, cfg.SMTPHost)
// PlainAuth itself refuses to send credentials in the clear, which would
// report as an auth failure rather than as the transport problem it is.
if !result.TLSEstablished && !isLocalhostHost(cfg.SMTPHost) {
return result, fmt.Errorf(
"refusing to authenticate over an unencrypted connection: enable TLS, or allow-insecure for a local test server")
}
if err = client.Auth(auth); err != nil {
return result, fmt.Errorf("smtp auth failed: %w", err)
}
result.AuthAccepted = true
}
}
return result, nil
}
func isLocalhostHost(host string) bool {
h := strings.ToLower(strings.TrimSpace(host))
if i := strings.LastIndex(h, ":"); i >= 0 && !strings.Contains(h, "]") {
h = h[:i]
}
return h == "localhost" || h == "127.0.0.1" || h == "::1"
}

View File

@@ -0,0 +1,205 @@
package email
import (
"bufio"
"context"
"net"
"strings"
"sync"
"testing"
"time"
)
// fakeSMTPServer is a deliberately tiny SMTP responder: just enough of the
// protocol for the probe to negotiate a connection. It exists so the probe can be
// exercised against a real socket and a real EHLO/STARTTLS exchange instead of a
// mock that would pass even if the transport code were wrong.
type fakeSMTPServer struct {
listener net.Listener
advertised []string
greeting string
mu sync.Mutex
connections int
}
func startFakeSMTPServer(t *testing.T, advertised []string) *fakeSMTPServer {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen error = %v", err)
}
server := &fakeSMTPServer{listener: listener, advertised: advertised, greeting: "220 fake ESMTP ready"}
t.Cleanup(func() { _ = listener.Close() })
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
server.mu.Lock()
server.connections++
server.mu.Unlock()
go server.handle(conn)
}
}()
return server
}
func (s *fakeSMTPServer) address() (string, int) {
host, port, err := net.SplitHostPort(s.listener.Addr().String())
if err != nil {
panic(err)
}
return host, atoiOrFail(port)
}
func atoiOrFail(value string) int {
total := 0
for _, r := range value {
total = total*10 + int(r-'0')
}
return total
}
func (s *fakeSMTPServer) handle(conn net.Conn) {
defer func() { _ = conn.Close() }()
writer := bufio.NewWriter(conn)
_, _ = writer.WriteString(s.greeting + "\r\n")
_ = writer.Flush()
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
switch {
case strings.HasPrefix(strings.ToUpper(line), "EHLO"), strings.HasPrefix(strings.ToUpper(line), "HELO"):
_, _ = writer.WriteString("250-" + s.listener.Addr().String() + "\r\n")
for i, ext := range s.advertised {
sep := "-"
if i == len(s.advertised)-1 {
sep = " "
}
_, _ = writer.WriteString("250" + sep + ext + "\r\n")
}
if len(s.advertised) == 0 {
_, _ = writer.WriteString("250 SIZE 35882577\r\n")
}
_ = writer.Flush()
case strings.HasPrefix(strings.ToUpper(line), "QUIT"):
_, _ = writer.WriteString("221 Bye\r\n")
_ = writer.Flush()
return
case strings.HasPrefix(strings.ToUpper(line), "STARTTLS"):
_, _ = writer.WriteString("454 TLS not available\r\n")
_ = writer.Flush()
case strings.HasPrefix(strings.ToUpper(line), "AUTH"):
_, _ = writer.WriteString("535 Authentication credentials invalid\r\n")
_ = writer.Flush()
default:
_, _ = writer.WriteString("250 OK\r\n")
_ = writer.Flush()
}
}
}
func probeConfig(host string, port int) ClientConfig {
return ClientConfig{
Provider: ProviderSMTP,
SMTPHost: host,
SMTPPort: port,
SMTPUseTLS: false,
}
}
func TestProbeSMTPConnectsAndReportsCapabilities(t *testing.T) {
server := startFakeSMTPServer(t, []string{"PIPELINING", "8BITMIME", "SIZE 35882577"})
host, port := server.address()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
result, err := ProbeSMTP(ctx, probeConfig(host, port))
if err != nil {
t.Fatalf("ProbeSMTP() error = %v", err)
}
if result.Host != host || result.Port != port {
t.Fatalf("probe reported %s:%d, want %s:%d", result.Host, result.Port, host, port)
}
if !contains(result.Capabilities, "8BITMIME") || !contains(result.Capabilities, "PIPELINING") {
t.Fatalf("capabilities = %v, want the advertised extensions", result.Capabilities)
}
if contains(result.Capabilities, "STARTTLS") {
t.Fatal("STARTTLS was not advertised but shows as a capability")
}
// A server with no credentials configured must not report an auth attempt.
if result.AuthAttempted {
t.Fatal("a binding with no credentials should not attempt auth")
}
}
// The test-server escape hatch: a self-signed endpoint that advertises STARTTLS
// would otherwise fail verification and take the probe down with it. Here the
// fake refuses the upgrade outright, so the probe must name the transport step
// and still report what the server offered.
func TestProbeSMTPReportsSTARTTLSOfferedAndFailsTheUpgrade(t *testing.T) {
server := startFakeSMTPServer(t, []string{"STARTTLS", "PIPELINING"})
host, port := server.address()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
result, err := ProbeSMTP(ctx, probeConfig(host, port))
if err == nil {
t.Fatal("expected the refused STARTTLS to be reported rather than treated as healthy")
}
if result == nil {
t.Fatal("a failed probe must still return what it reached")
}
if !result.STARTTLSOffered {
t.Fatal("STARTTLS was advertised but not reported")
}
if result.TLSEstablished {
t.Fatal("TLS must not be reported as established when the upgrade was refused")
}
if !strings.Contains(err.Error(), "starttls") {
t.Fatalf("error %q should name the transport step that failed", err)
}
if !strings.Contains(err.Error(), "allow-insecure") {
t.Fatalf("error %q should point at the test-server setting", err)
}
}
func TestProbeSMTPFailsWhenNothingIsListening(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen error = %v", err)
}
host, port, err := net.SplitHostPort(listener.Addr().String())
if err != nil {
t.Fatalf("split error = %v", err)
}
// Close immediately so the port is almost certainly free.
_ = listener.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := ProbeSMTP(ctx, probeConfig(host, atoiOrFail(port))); err == nil {
t.Fatal("expected an unreachable server to fail the probe")
}
}
func TestProbeSMTPRequiresAHost(t *testing.T) {
if _, err := ProbeSMTP(context.Background(), ClientConfig{Provider: ProviderSMTP}); err == nil {
t.Fatal("expected a missing host to be reported instead of dialling nothing")
}
}
func contains(values []string, needle string) bool {
for _, value := range values {
if value == needle {
return true
}
}
return false
}

198
internal/email/types.go Normal file
View File

@@ -0,0 +1,198 @@
package email
// DeliveryProvider identifies email sending service.
type DeliveryProvider string
const (
ProviderSMTP DeliveryProvider = "smtp"
ProviderBrevo DeliveryProvider = "brevo"
ProviderSendGrid DeliveryProvider = "sendgrid"
ProviderResend DeliveryProvider = "resend"
ProviderPostmark DeliveryProvider = "postmark"
ProviderMailgun DeliveryProvider = "mailgun"
)
// SendEmailParams defines parameters for sending an email.
type SendEmailParams struct {
FromEmail string `json:"fromEmail"`
FromName string `json:"fromName,omitempty"`
ToEmail string `json:"toEmail"`
ToName string `json:"toName,omitempty"`
ReplyTo string `json:"replyTo,omitempty"`
Subject string `json:"subject"`
BodyText string `json:"bodyText"`
BodyHTML string `json:"bodyHtml,omitempty"`
MessageID string `json:"messageId,omitempty"`
InReplyTo string `json:"inReplyTo,omitempty"`
References string `json:"references,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Attachments []EmailAttachment `json:"attachments,omitempty"`
}
// EmailAttachment defines file attachments in email.
type EmailAttachment struct {
Filename string `json:"filename"`
ContentType string `json:"contentType"`
ContentB64 string `json:"contentB64"`
}
// ClientConfig holds configuration for initializing Email client.
type ClientConfig struct {
Provider DeliveryProvider
APIKey string
BaseURL string
SMTPHost string
SMTPPort int
SMTPUser string
SMTPPassword string
SMTPUseTLS bool
// SMTPAllowInsecure skips certificate verification and refuses opportunistic
// STARTTLS. It exists so a channel can be bound to a self-signed test server
// such as Mailpit or MailHog; it weakens transport security and must never be
// set for a real mailbox.
SMTPAllowInsecure bool
Domain string // For Mailgun (e.g. mg.example.com)
}
// InboundEmailPayload represents normalized parsed inbound email.
type InboundEmailPayload struct {
FromEmail string `json:"fromEmail"`
FromName string `json:"fromName,omitempty"`
ToEmail string `json:"toEmail"`
ToName string `json:"toName,omitempty"`
Subject string `json:"subject"`
BodyText string `json:"bodyText"`
BodyHTML string `json:"bodyHtml,omitempty"`
MessageID string `json:"messageId,omitempty"`
InReplyTo string `json:"inReplyTo,omitempty"`
References string `json:"references,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Attachments []EmailAttachment `json:"attachments,omitempty"`
}
// --- Provider specific Inbound & Outbound Structs ---
// BrevoSendEmailRequest payload for Brevo v3 API.
type BrevoSendEmailRequest struct {
Sender BrevoEmailContact `json:"sender"`
To []BrevoEmailContact `json:"to"`
ReplyTo *BrevoEmailContact `json:"replyTo,omitempty"`
Subject string `json:"subject"`
HTMLContent string `json:"htmlContent,omitempty"`
TextContent string `json:"textContent,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
}
type BrevoEmailContact struct {
Name string `json:"name,omitempty"`
Email string `json:"email"`
}
// SendGridSendEmailRequest payload for SendGrid v3 API.
type SendGridSendEmailRequest struct {
Personalizations []SendGridPersonalization `json:"personalizations"`
From SendGridContact `json:"from"`
ReplyTo *SendGridContact `json:"reply_to,omitempty"`
Subject string `json:"subject"`
Content []SendGridContent `json:"content"`
Headers map[string]string `json:"headers,omitempty"`
}
type SendGridPersonalization struct {
To []SendGridContact `json:"to"`
Subject string `json:"subject,omitempty"`
}
type SendGridContact struct {
Email string `json:"email"`
Name string `json:"name,omitempty"`
}
type SendGridContent struct {
Type string `json:"type"`
Value string `json:"value"`
}
// ResendSendEmailRequest payload for Resend API.
type ResendSendEmailRequest struct {
From string `json:"from"`
To []string `json:"to"`
ReplyTo string `json:"reply_to,omitempty"`
Subject string `json:"subject"`
Text string `json:"text,omitempty"`
HTML string `json:"html,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Attachments []ResendAttachment `json:"attachments,omitempty"`
}
type ResendAttachment struct {
Filename string `json:"filename"`
Content string `json:"content"`
}
// PostmarkSendEmailRequest payload for Postmark API.
type PostmarkSendEmailRequest struct {
From string `json:"From"`
To string `json:"To"`
ReplyTo string `json:"ReplyTo,omitempty"`
Subject string `json:"Subject"`
TextBody string `json:"TextBody,omitempty"`
HtmlBody string `json:"HtmlBody,omitempty"`
Headers []PostmarkHeader `json:"Headers,omitempty"`
Attachments []PostmarkAttach `json:"Attachments,omitempty"`
}
type PostmarkHeader struct {
Name string `json:"Name"`
Value string `json:"Value"`
}
type PostmarkAttach struct {
Name string `json:"Name"`
Content string `json:"Content"`
ContentType string `json:"ContentType"`
}
// GenericInboundWebhook represents standard webhook JSON format (Cloudflare Email Routing, AWS SES, Generic).
type GenericInboundWebhook struct {
From string `json:"from"`
FromName string `json:"from_name,omitempty"`
To string `json:"to"`
ToName string `json:"to_name,omitempty"`
Subject string `json:"subject"`
Text string `json:"text,omitempty"`
HTML string `json:"html,omitempty"`
Body string `json:"body,omitempty"`
MessageID string `json:"message_id,omitempty"`
InReplyTo string `json:"in_reply_to,omitempty"`
References string `json:"references,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
}
// BrevoInboundItem represents an item in Brevo inbound webhook.
type BrevoInboundItem struct {
UUID []string `json:"Uuid,omitempty"`
Sender string `json:"Sender,omitempty"`
Recipient string `json:"Recipient,omitempty"`
Subject string `json:"Subject,omitempty"`
RawHTMLBody string `json:"RawHtmlBody,omitempty"`
RawTextBody string `json:"RawTextBody,omitempty"`
Headers map[string]string `json:"Headers,omitempty"`
}
type BrevoInboundWebhook struct {
Items []BrevoInboundItem `json:"items,omitempty"`
}
// PostmarkInboundWebhook represents Postmark inbound email payload.
type PostmarkInboundWebhook struct {
From string `json:"From"`
FromName string `json:"FromName,omitempty"`
To string `json:"To"`
Subject string `json:"Subject"`
TextBody string `json:"TextBody,omitempty"`
HtmlBody string `json:"HtmlBody,omitempty"`
MessageID string `json:"MessageID,omitempty"`
MailboxHash string `json:"MailboxHash,omitempty"`
Headers []PostmarkHeader `json:"Headers,omitempty"`
}