Files
gochat/internal/automation/action_delivery.go
T

268 lines
7.2 KiB
Go

package automation
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/mail"
"net/smtp"
"os"
"strconv"
"strings"
"time"
)
const (
defaultActionDeliveryAttempts = 3
defaultActionDeliveryTimeout = 10 * time.Second
TaskTypeAutomationWebhookDelivery = "automation:webhook_delivery"
TaskTypeAutomationTranscriptDelivery = "automation:transcript_delivery"
TaskTypeAutomationTeamEmailDelivery = "automation:team_email_delivery"
)
// ActionDeliveryResult is copied into AutomationExecution.action_results so
// external actions are inspectable even when the durable worker is not yet in use.
type ActionDeliveryResult struct {
DeliveryType string
Target string
Attempts int
ResponseCode int
ResponseBody string
Retryable bool
Queued bool
}
type AutomationWebhookRequest struct {
AccountID uint
ConversationID uint
EventName string
URL string
Payload map[string]interface{}
}
type AutomationTranscriptRequest struct {
AccountID uint
ConversationID uint
Recipient string
Subject string
Body string
}
type AutomationWebhookDeliverer interface {
DeliverWebhook(ctx context.Context, req AutomationWebhookRequest) (ActionDeliveryResult, error)
}
type AutomationTranscriptDeliverer interface {
DeliverTranscript(ctx context.Context, req AutomationTranscriptRequest) (ActionDeliveryResult, error)
}
type HTTPAutomationWebhookDeliverer struct {
client *http.Client
maxAttempts int
retryDelay time.Duration
}
func NewHTTPAutomationWebhookDeliverer(client *http.Client, maxAttempts int, retryDelay time.Duration) *HTTPAutomationWebhookDeliverer {
if client == nil {
client = &http.Client{Timeout: defaultActionDeliveryTimeout}
}
if client.Timeout == 0 {
client.Timeout = defaultActionDeliveryTimeout
}
if maxAttempts <= 0 {
maxAttempts = defaultActionDeliveryAttempts
}
return &HTTPAutomationWebhookDeliverer{client: client, maxAttempts: maxAttempts, retryDelay: retryDelay}
}
func (d *HTTPAutomationWebhookDeliverer) DeliverWebhook(ctx context.Context, req AutomationWebhookRequest) (ActionDeliveryResult, error) {
result := ActionDeliveryResult{DeliveryType: "webhook", Target: req.URL}
if strings.TrimSpace(req.URL) == "" {
return result, errors.New("webhook URL is required")
}
body, err := json.Marshal(req.Payload)
if err != nil {
return result, fmt.Errorf("marshal webhook payload: %w", err)
}
var lastErr error
for attempt := 1; attempt <= d.maxAttempts; attempt++ {
result.Attempts = attempt
result.ResponseCode = 0
result.ResponseBody = ""
attemptCtx, cancel := context.WithTimeout(ctx, d.client.Timeout)
httpReq, err := http.NewRequestWithContext(attemptCtx, http.MethodPost, req.URL, bytes.NewReader(body))
if err != nil {
cancel()
return result, fmt.Errorf("build webhook request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("X-Webhook-Event", fmt.Sprintf("automation_event.%s", req.EventName))
resp, err := d.client.Do(httpReq)
cancel()
if err != nil {
lastErr = fmt.Errorf("send webhook: %w", err)
result.Retryable = true
} else {
result.ResponseCode = resp.StatusCode
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
result.ResponseBody = string(responseBody)
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
result.Retryable = false
return result, nil
}
result.Retryable = resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests
lastErr = fmt.Errorf("webhook returned HTTP %d", resp.StatusCode)
}
if !result.Retryable || attempt == d.maxAttempts {
break
}
if d.retryDelay > 0 {
select {
case <-ctx.Done():
return result, ctx.Err()
case <-time.After(d.retryDelay):
}
}
}
return result, lastErr
}
type SMTPAutomationTranscriptDeliverer struct {
Address string
Port int
Username string
Password string
From string
maxAttempts int
retryDelay time.Duration
timeout time.Duration
}
func NewEnvAutomationTranscriptDeliverer() *SMTPAutomationTranscriptDeliverer {
return &SMTPAutomationTranscriptDeliverer{
Address: strings.TrimSpace(os.Getenv("SMTP_ADDRESS")),
Port: actionEnvInt("SMTP_PORT", 587),
Username: firstActionEnv("SMTP_USERNAME", "SMTP_LOGIN"),
Password: os.Getenv("SMTP_PASSWORD"),
From: firstActionEnv("MAILER_SENDER_EMAIL", "SMTP_FROM"),
maxAttempts: defaultActionDeliveryAttempts,
timeout: defaultActionDeliveryTimeout,
}
}
func (d *SMTPAutomationTranscriptDeliverer) DeliverTranscript(ctx context.Context, req AutomationTranscriptRequest) (ActionDeliveryResult, error) {
result := ActionDeliveryResult{DeliveryType: "email_transcript", Target: req.Recipient}
if strings.TrimSpace(req.Recipient) == "" {
return result, errors.New("recipient email is required")
}
if d == nil || strings.TrimSpace(d.Address) == "" {
result.Attempts = 1
result.ResponseBody = "smtp_not_configured"
return result, nil
}
maxAttempts := d.maxAttempts
if maxAttempts <= 0 {
maxAttempts = defaultActionDeliveryAttempts
}
timeout := d.timeout
if timeout <= 0 {
timeout = defaultActionDeliveryTimeout
}
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
result.Attempts = attempt
attemptCtx, cancel := context.WithTimeout(ctx, timeout)
lastErr = d.send(attemptCtx, req)
cancel()
if lastErr == nil {
result.Retryable = false
return result, nil
}
result.Retryable = true
result.ResponseBody = lastErr.Error()
if attempt == maxAttempts {
break
}
if d.retryDelay > 0 {
select {
case <-ctx.Done():
return result, ctx.Err()
case <-time.After(d.retryDelay):
}
}
}
return result, lastErr
}
func (d *SMTPAutomationTranscriptDeliverer) send(ctx context.Context, req AutomationTranscriptRequest) error {
fromHeader := strings.TrimSpace(d.From)
if fromHeader == "" {
fromHeader = "Chatwoot <accounts@chatwoot.com>"
}
fromAddress := fromHeader
if parsed, err := mail.ParseAddress(fromHeader); err == nil {
fromAddress = parsed.Address
}
message := smtpTranscriptMessage(fromHeader, req.Recipient, req.Subject, req.Body)
addr := fmt.Sprintf("%s:%d", strings.TrimSpace(d.Address), d.Port)
var auth smtp.Auth
if strings.TrimSpace(d.Username) != "" {
auth = smtp.PlainAuth("", strings.TrimSpace(d.Username), d.Password, strings.TrimSpace(d.Address))
}
done := make(chan error, 1)
go func() {
done <- smtp.SendMail(addr, auth, fromAddress, []string{req.Recipient}, []byte(message))
}()
select {
case <-ctx.Done():
return ctx.Err()
case err := <-done:
return err
}
}
func smtpTranscriptMessage(from, to, subject, body string) string {
return strings.Join([]string{
"From: " + from,
"To: " + to,
"Subject: " + subject,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"",
body,
}, "\r\n")
}
func firstActionEnv(keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
}
return ""
}
func actionEnvInt(key string, fallback int) int {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}