268 lines
7.2 KiB
Go
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
|
|
}
|