chore: import clean Agent Desk snapshot (2/7)
This commit is contained in:
494
internal/email/client.go
Normal file
494
internal/email/client.go
Normal 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"
|
||||
}
|
||||
172
internal/email/client_test.go
Normal file
172
internal/email/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
258
internal/email/inbound_parser.go
Normal file
258
internal/email/inbound_parser.go
Normal 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
149
internal/email/probe.go
Normal 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"
|
||||
}
|
||||
205
internal/email/probe_test.go
Normal file
205
internal/email/probe_test.go
Normal 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
198
internal/email/types.go
Normal 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user