diff --git a/internal/ai_client/client.go b/internal/ai_client/client.go index c395f06..1a8b589 100644 --- a/internal/ai_client/client.go +++ b/internal/ai_client/client.go @@ -73,7 +73,7 @@ func (d *Dispatcher) Dispatch(task *database.Task) { SessionID: task.ThreadID, History: history, CallbackURL: callbackURL, - Metadata: map[string]string{"task_uuid": task.TaskUUID}, + Metadata: buildDispatchMetadata(task), } // Update status to AI_PROCESSING before first attempt. @@ -195,3 +195,18 @@ func (d *Dispatcher) buildHistory(threadID string) ([]historyEntry, error) { return history, nil } + +// buildDispatchMetadata constructs the metadata map for OpenClaw dispatch, +// merging the task UUID with any DispatchContext from the rules pipeline. +func buildDispatchMetadata(task *database.Task) map[string]string { + metadata := map[string]string{"task_uuid": task.TaskUUID} + if task.DispatchContext != "" { + var ctx map[string]string + if err := json.Unmarshal([]byte(task.DispatchContext), &ctx); err == nil { + for k, v := range ctx { + metadata[k] = v + } + } + } + return metadata +} diff --git a/internal/config/config.go b/internal/config/config.go index 13e8d1d..e0adcb0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,6 +31,7 @@ type Config struct { BridgeCallbackToken string SystemEmail string WhitelistEmails []string + BlocklistEmails []string // Optional IMAPProxyURL string @@ -74,6 +75,14 @@ func Load() (*Config, error) { } } + if raw := os.Getenv("BLOCKLIST_EMAILS"); raw != "" { + for _, email := range strings.Split(raw, ",") { + if trimmed := strings.TrimSpace(email); trimmed != "" { + cfg.BlocklistEmails = append(cfg.BlocklistEmails, trimmed) + } + } + } + if cfg.ListenAddr == "" { cfg.ListenAddr = ":8080" } diff --git a/internal/database/models.go b/internal/database/models.go index f779396..a1cbac0 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -24,6 +24,7 @@ type Task struct { Sender string Subject string BodyPlain string + DispatchContext string // JSON metadata from external rules pipeline. AIResponse string Status string `gorm:"index;not null"` AttemptOpenClaw int `gorm:"default:0"` diff --git a/internal/mail/imap.go b/internal/mail/imap.go index 6fe6f32..da22273 100644 --- a/internal/mail/imap.go +++ b/internal/mail/imap.go @@ -3,6 +3,7 @@ package mail import ( "context" "crypto/tls" + "encoding/json" "fmt" "io" "log/slog" @@ -21,6 +22,7 @@ import ( "thuanle.me/claw-email-bridge/internal/config" "thuanle.me/claw-email-bridge/internal/database" "thuanle.me/claw-email-bridge/internal/logging" + "thuanle.me/claw-email-bridge/internal/rules" ) // IMAPWatcher monitors an IMAP mailbox via IDLE and processes new emails. @@ -32,6 +34,8 @@ type IMAPWatcher struct { dialIMAP func(addr string, options *imapclient.Options) (*imapclient.Client, error) dialIMAPViaProxy func(addr, proxyURL string, options *imapclient.Options) (*imapclient.Client, error) + rules *rules.Pipeline + // onReceived is called after a task is saved as RECEIVED. // This will be wired to the OpenClaw dispatch in a future step. OnReceived func(task *database.Task) @@ -57,11 +61,20 @@ var socks5DialerFactory = proxy.SOCKS5 // NewIMAPWatcher creates a new IMAP watcher. func NewIMAPWatcher(cfg *config.Config, db *gorm.DB) *IMAPWatcher { + var externalRules []rules.Rule + if len(cfg.WhitelistEmails) > 0 { + externalRules = append(externalRules, rules.NewWhitelistRule(cfg.WhitelistEmails)) + } + if len(cfg.BlocklistEmails) > 0 { + externalRules = append(externalRules, rules.NewBlocklistRule(cfg.BlocklistEmails)) + } + return &IMAPWatcher{ cfg: cfg, db: db, dialIMAP: imapclient.DialTLS, dialIMAPViaProxy: dialTLSViaSOCKS5, + rules: rules.NewPipeline(externalRules), } } @@ -343,10 +356,15 @@ func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.Fetch return } - // 2. Whitelist: skip if sender not in allowed list. - if !w.isWhitelisted(sender) { - log.Info("imap: sender not whitelisted, ignoring", "sender", sender) - w.saveIgnored(messageID, sender, subject, "not whitelisted") + // 2. External rules: evaluate operator-configurable policy. + ruleResult := w.rules.Evaluate(rules.EmailContext{ + Sender: sender, + Subject: subject, + MessageID: messageID, + }) + if !ruleResult.Accepted { + log.Info("imap: rejected by external rules", "sender", sender, "reason", ruleResult.Reason) + w.saveIgnored(messageID, sender, subject, ruleResult.Reason) w.markSeen(c, buf.UID) return } @@ -404,13 +422,14 @@ func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.Fetch // 5. Save task with status RECEIVED. taskUUID := uuid.New().String() task := &database.Task{ - TaskUUID: taskUUID, - ThreadID: threadID, - MessageID: messageID, - Sender: sender, - Subject: subject, - BodyPlain: bodyPlain, - Status: database.StatusReceived, + TaskUUID: taskUUID, + ThreadID: threadID, + MessageID: messageID, + Sender: sender, + Subject: subject, + BodyPlain: bodyPlain, + DispatchContext: marshalDispatchContext(ruleResult.Metadata), + Status: database.StatusReceived, } taskLog := logging.TaskLogger(taskUUID, threadID, messageID) @@ -453,19 +472,13 @@ func (w *IMAPWatcher) saveIgnored(messageID, sender, subject, reason string) { } } -// isWhitelisted checks if the sender is in the allowed list. -// Empty whitelist means all senders are allowed. -func (w *IMAPWatcher) isWhitelisted(sender string) bool { - if len(w.cfg.WhitelistEmails) == 0 { - return true +// marshalDispatchContext serializes rule metadata to JSON. +func marshalDispatchContext(metadata map[string]string) string { + if len(metadata) == 0 { + return "" } - lower := strings.ToLower(sender) - for _, email := range w.cfg.WhitelistEmails { - if strings.ToLower(email) == lower { - return true - } - } - return false + data, _ := json.Marshal(metadata) + return string(data) } // markSeen flags a message as \Seen in IMAP. diff --git a/internal/rules/pipeline.go b/internal/rules/pipeline.go index 63f89bd..d8debb0 100644 --- a/internal/rules/pipeline.go +++ b/internal/rules/pipeline.go @@ -27,4 +27,4 @@ func (p *Pipeline) Evaluate(ctx EmailContext) RuleResult { } } return merged -} \ No newline at end of file +} diff --git a/internal/rules/pipeline_test.go b/internal/rules/pipeline_test.go index ac37a5f..fe6f951 100644 --- a/internal/rules/pipeline_test.go +++ b/internal/rules/pipeline_test.go @@ -95,12 +95,12 @@ type stubRule struct { result RuleResult } -func (s *stubRule) Name() string { return s.name } +func (s *stubRule) Name() string { return s.name } func (s *stubRule) Evaluate(_ EmailContext) RuleResult { return s.result } type trackingRule struct { called bool } -func (t *trackingRule) Name() string { return "tracker" } -func (t *trackingRule) Evaluate(_ EmailContext) RuleResult { t.called = true; return Accept() } \ No newline at end of file +func (t *trackingRule) Name() string { return "tracker" } +func (t *trackingRule) Evaluate(_ EmailContext) RuleResult { t.called = true; return Accept() } diff --git a/internal/rules/types.go b/internal/rules/types.go index 8c21fe8..3afb3f7 100644 --- a/internal/rules/types.go +++ b/internal/rules/types.go @@ -38,4 +38,4 @@ func (r RuleResult) WithMetadata(key, value string) RuleResult { type Rule interface { Name() string Evaluate(ctx EmailContext) RuleResult -} \ No newline at end of file +} diff --git a/internal/rules/types_test.go b/internal/rules/types_test.go index 2bc2be4..1ac2d9a 100644 --- a/internal/rules/types_test.go +++ b/internal/rules/types_test.go @@ -39,4 +39,4 @@ func TestEmailContext_Fields(t *testing.T) { if ctx.Sender != "user@example.com" { t.Fatal("sender mismatch") } -} \ No newline at end of file +} diff --git a/internal/rules/whitelist.go b/internal/rules/whitelist.go index 0eaca8a..a3bead9 100644 --- a/internal/rules/whitelist.go +++ b/internal/rules/whitelist.go @@ -27,4 +27,4 @@ func (r *WhitelistRule) Evaluate(ctx EmailContext) RuleResult { return Accept() } return Reject("not whitelisted") -} \ No newline at end of file +}