Files
agent-desk-ng/internal/email/client.go

495 lines
14 KiB
Go

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"
}