189 lines
4.7 KiB
Go
189 lines
4.7 KiB
Go
package rules
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
type Engine struct {
|
|
db *pgxpool.Pool
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func NewEngine(db *pgxpool.Pool) *Engine {
|
|
return &Engine{
|
|
db: db,
|
|
logger: slog.Default().With("component", "rules-engine"),
|
|
}
|
|
}
|
|
|
|
type Rule struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Priority int `json:"priority"`
|
|
Conditions []Condition `json:"conditions"`
|
|
Actions []Action `json:"actions"`
|
|
}
|
|
|
|
type Condition struct {
|
|
Field string `json:"field"` // from, to, subject, body, has_attachment
|
|
Operator string `json:"operator"` // contains, equals, starts_with, ends_with, matches
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
type Action struct {
|
|
Type string `json:"type"` // label, move, archive, delete, mark_read, forward, webhook
|
|
Value string `json:"value"` // label name, folder name, email, webhook_id
|
|
}
|
|
|
|
type Message struct {
|
|
ID string `json:"id"`
|
|
From string `json:"from"`
|
|
To []string `json:"to"`
|
|
Subject string `json:"subject"`
|
|
BodyText string `json:"body_text"`
|
|
HasAttachments bool `json:"has_attachments"`
|
|
}
|
|
|
|
func (e *Engine) Evaluate(ctx context.Context, userID string, msg *Message) error {
|
|
rows, err := e.db.Query(ctx, `
|
|
SELECT id, name, conditions, actions
|
|
FROM mail_rules
|
|
WHERE user_id = $1 AND is_active = true
|
|
ORDER BY priority ASC
|
|
`, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("query rules: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var (
|
|
ruleID string
|
|
name string
|
|
condJSON []byte
|
|
actJSON []byte
|
|
)
|
|
if err := rows.Scan(&ruleID, &name, &condJSON, &actJSON); err != nil {
|
|
e.logger.Error("scan rule", "error", err)
|
|
continue
|
|
}
|
|
|
|
var conditions []Condition
|
|
var actions []Action
|
|
json.Unmarshal(condJSON, &conditions)
|
|
json.Unmarshal(actJSON, &actions)
|
|
|
|
if matchesAll(conditions, msg) {
|
|
e.logger.Info("rule matched", "rule_id", ruleID, "rule_name", name, "message_id", msg.ID)
|
|
for _, action := range actions {
|
|
if err := e.executeAction(ctx, action, msg); err != nil {
|
|
e.logger.Error("action failed", "rule_id", ruleID, "action", action.Type, "error", err)
|
|
}
|
|
}
|
|
// Increment match count
|
|
e.db.Exec(ctx, `UPDATE mail_rules SET match_count = match_count + 1 WHERE id = $1`, ruleID)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func matchesAll(conditions []Condition, msg *Message) bool {
|
|
for _, cond := range conditions {
|
|
if !matchCondition(cond, msg) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func matchCondition(cond Condition, msg *Message) bool {
|
|
var fieldValue string
|
|
switch cond.Field {
|
|
case "from":
|
|
fieldValue = msg.From
|
|
case "to":
|
|
fieldValue = strings.Join(msg.To, ", ")
|
|
case "subject":
|
|
fieldValue = msg.Subject
|
|
case "body":
|
|
fieldValue = msg.BodyText
|
|
case "has_attachment":
|
|
if msg.HasAttachments {
|
|
fieldValue = "true"
|
|
} else {
|
|
fieldValue = "false"
|
|
}
|
|
default:
|
|
return false
|
|
}
|
|
|
|
fieldLower := strings.ToLower(fieldValue)
|
|
valueLower := strings.ToLower(cond.Value)
|
|
|
|
switch cond.Operator {
|
|
case "contains":
|
|
return strings.Contains(fieldLower, valueLower)
|
|
case "equals":
|
|
return fieldLower == valueLower
|
|
case "starts_with":
|
|
return strings.HasPrefix(fieldLower, valueLower)
|
|
case "ends_with":
|
|
return strings.HasSuffix(fieldLower, valueLower)
|
|
case "not_contains":
|
|
return !strings.Contains(fieldLower, valueLower)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (e *Engine) executeAction(ctx context.Context, action Action, msg *Message) error {
|
|
switch action.Type {
|
|
case "label":
|
|
_, err := e.db.Exec(ctx, `
|
|
UPDATE messages SET labels = array_append(labels, $1), updated_at = NOW()
|
|
WHERE id = $2 AND NOT ($1 = ANY(labels))
|
|
`, action.Value, msg.ID)
|
|
return err
|
|
case "move":
|
|
_, err := e.db.Exec(ctx, `
|
|
UPDATE messages SET folder_id = (
|
|
SELECT id FROM mail_folders WHERE account_id = (
|
|
SELECT account_id FROM messages WHERE id = $2
|
|
) AND name = $1 LIMIT 1
|
|
), updated_at = NOW()
|
|
WHERE id = $2
|
|
`, action.Value, msg.ID)
|
|
return err
|
|
case "archive":
|
|
_, err := e.db.Exec(ctx, `
|
|
UPDATE messages SET flags = array_append(flags, '\Archive'), updated_at = NOW()
|
|
WHERE id = $1
|
|
`, msg.ID)
|
|
return err
|
|
case "mark_read":
|
|
_, err := e.db.Exec(ctx, `
|
|
UPDATE messages SET flags = array_append(flags, '\Seen'), updated_at = NOW()
|
|
WHERE id = $1 AND NOT ('\Seen' = ANY(flags))
|
|
`, msg.ID)
|
|
return err
|
|
case "delete":
|
|
_, err := e.db.Exec(ctx, `
|
|
UPDATE messages SET flags = array_append(flags, '\Deleted'), updated_at = NOW()
|
|
WHERE id = $1
|
|
`, msg.ID)
|
|
return err
|
|
case "webhook":
|
|
// Webhook execution is handled by the webhooks package
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unknown action type: %s", action.Type)
|
|
}
|
|
}
|