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