Files
gowebmail/internal/syncer/rules.go
T

273 lines
8.9 KiB
Go

package syncer
import (
"context"
"fmt"
"log"
"strings"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/email"
"github.com/ghostersk/gowebmail/internal/graph"
"github.com/ghostersk/gowebmail/internal/models"
"github.com/ghostersk/gowebmail/internal/rules"
)
// matchRule evaluates a message against an account's active rules (as loaded from the DB)
// and returns the matching models.Rule (with full action data), or nil if none match.
func matchRule(msg *models.Message, accountEmail string, activeRules []models.Rule) *models.Rule {
if len(activeRules) == 0 {
return nil
}
engineRules := make([]rules.Rule, 0, len(activeRules))
for _, r := range activeRules {
conds := make([]rules.Condition, 0, len(r.Conditions))
for _, c := range r.Conditions {
conds = append(conds, rules.Condition{Field: c.Field, Op: c.Op, Value: c.Value})
}
engineRules = append(engineRules, rules.Rule{
ID: r.ID, Priority: r.Priority, Conditions: conds, MatchType: r.MatchType,
Action: r.Action, ActionValue: r.ActionValue,
})
}
mf := rules.MessageFields{
From: msg.FromEmail, To: msg.ToList, Subject: msg.Subject, Body: msg.BodyText,
HasAttachment: msg.HasAttachment, RecipientType: recipientType(msg, accountEmail),
}
matched := rules.Match(mf, engineRules)
if matched == nil {
return nil
}
for i := range activeRules {
if activeRules[i].ID == matched.ID {
return &activeRules[i]
}
}
return nil
}
func recipientType(msg *models.Message, accountEmail string) string {
if msg.CCList != "" && containsAddress(msg.CCList, accountEmail) {
return "cc"
}
if msg.BCCList != "" && containsAddress(msg.BCCList, accountEmail) {
return "bcc"
}
return "to"
}
func containsAddress(list, addr string) bool {
// list is comma-separated; a substring check is enough since we only use this
// to pick a synthetic recipient_type label, not for anything security-relevant.
for _, part := range splitAndTrim(list) {
if part == addr {
return true
}
}
return false
}
func splitAndTrim(s string) []string {
var out []string
cur := ""
for _, r := range s {
if r == ',' {
out = append(out, trimLower(cur))
cur = ""
continue
}
cur += string(r)
}
if cur != "" {
out = append(out, trimLower(cur))
}
return out
}
func trimLower(s string) string {
start, end := 0, len(s)
for start < end && (s[start] == ' ' || s[start] == '\t') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
end--
}
return strings.ToLower(s[start:end])
}
func parseUID(s string) uint32 {
var uid uint32
fmt.Sscanf(s, "%d", &uid)
return uid
}
// ---- Spam blocklist (Settings > Security > Spam Block) ----
// A user-managed list of blocked senders, separate from the Rules engine so it gets its own
// simple add/remove UI instead of the generic condition/action rule builder — but enforced
// the same way the Rules engine's mark_as_spam action already is: move to the account's Spam
// folder. Applied to every provider's newly-synced messages, mirroring where matchRule runs.
func (s *Scheduler) moveToSpamIMAP(account *models.EmailAccount, dbFolder *models.Folder, msg *models.Message) {
junk, err := s.db.GetFolderByType(account.ID, "spam")
if err != nil || junk == nil {
return
}
uid := parseUID(msg.RemoteUID)
s.db.EnqueueIMAPOp(&db.PendingIMAPOp{AccountID: account.ID, OpType: "move", RemoteUID: uid, FolderPath: dbFolder.FullPath, Extra: junk.FullPath})
s.TriggerAccountSync(account.ID)
}
func (s *Scheduler) moveToSpamGraph(gc *graph.Client, account *models.EmailAccount, msg *models.Message) {
junk, err := s.db.GetFolderByType(account.ID, "spam")
if err != nil || junk == nil {
return
}
if err := gc.MoveMessage(context.Background(), msg.RemoteUID, junk.FullPath); err != nil {
log.Printf("[spam-block] graph move: %v", err)
}
}
// ---- IMAP path ----
func (s *Scheduler) applyRuleIMAP(c *email.Client, account *models.EmailAccount, dbFolder *models.Folder, msg *models.Message, rule *models.Rule) {
uid := parseUID(msg.RemoteUID)
switch rule.Action {
case "move_to_folder":
dest, err := s.db.GetFolderByName(account.ID, rule.ActionValue)
if err != nil || dest == nil {
log.Printf("[rules] move_to_folder: folder %q not found for %s", rule.ActionValue, account.EmailAddress)
return
}
s.db.EnqueueIMAPOp(&db.PendingIMAPOp{AccountID: account.ID, OpType: "move", RemoteUID: uid, FolderPath: dbFolder.FullPath, Extra: dest.FullPath})
s.TriggerAccountSync(account.ID)
case "mark_as_spam":
junk, err := s.db.GetFolderByType(account.ID, "spam")
if err != nil || junk == nil {
log.Printf("[rules] mark_as_spam: no spam/junk folder found for %s", account.EmailAddress)
return
}
s.db.EnqueueIMAPOp(&db.PendingIMAPOp{AccountID: account.ID, OpType: "move", RemoteUID: uid, FolderPath: dbFolder.FullPath, Extra: junk.FullPath})
s.TriggerAccountSync(account.ID)
case "delete":
s.db.EnqueueIMAPOp(&db.PendingIMAPOp{AccountID: account.ID, OpType: "delete", RemoteUID: uid, FolderPath: dbFolder.FullPath})
s.TriggerAccountSync(account.ID)
case "mark_read":
if err := c.SetFlagByUID(dbFolder.FullPath, uid, `\Seen`, true); err != nil {
log.Printf("[rules] mark_read: %v", err)
}
case "forward":
s.ruleForwardIMAP(account, msg, rule.ActionValue)
case "auto_reply":
s.ruleAutoReplyIMAP(account, msg, rule)
}
}
func (s *Scheduler) ruleForwardIMAP(account *models.EmailAccount, msg *models.Message, to string) {
req := &models.ComposeRequest{
AccountID: account.ID,
To: []string{to},
Subject: "Fwd: " + msg.Subject,
BodyHTML: msg.BodyHTML,
BodyText: msg.BodyText,
}
if err := email.SendMessageFull(context.Background(), account, req, nil); err != nil {
log.Printf("[rules] forward to %s: %v", to, err)
}
}
func (s *Scheduler) ruleAutoReplyIMAP(account *models.EmailAccount, msg *models.Message, rule *models.Rule) {
recipient := msg.FromEmail
if recipient == "" {
return // never reply to a bounce/empty sender — avoids loops
}
if sent, err := s.db.HasRecentAutoReply(account.ID, rule.ID, recipient); err != nil || sent {
return
}
req := &models.ComposeRequest{
AccountID: account.ID,
To: []string{recipient},
Subject: rule.ActionValue,
BodyText: rule.ActionOptions.Body,
BodyHTML: rule.ActionOptions.Body,
}
if err := email.SendMessageFull(context.Background(), account, req, nil); err != nil {
log.Printf("[rules] auto_reply to %s: %v", recipient, err)
return
}
s.db.LogAutoReply(account.ID, rule.ID, recipient)
}
// ---- Graph (personal Outlook.com) path ----
// msg.RemoteUID already holds the opaque Graph message ID (set at construction in graphDeltaSync).
func (s *Scheduler) applyRuleGraph(gc *graph.Client, account *models.EmailAccount, msg *models.Message, rule *models.Rule) {
ctx := context.Background()
switch rule.Action {
case "move_to_folder":
dest, err := s.db.GetFolderByName(account.ID, rule.ActionValue)
if err != nil || dest == nil {
log.Printf("[rules] move_to_folder: folder %q not found for %s", rule.ActionValue, account.EmailAddress)
return
}
if err := gc.MoveMessage(ctx, msg.RemoteUID, dest.FullPath); err != nil {
log.Printf("[rules] graph move: %v", err)
}
case "mark_as_spam":
junk, err := s.db.GetFolderByType(account.ID, "spam")
if err != nil || junk == nil {
log.Printf("[rules] mark_as_spam: no spam/junk folder found for %s", account.EmailAddress)
return
}
if err := gc.MoveMessage(ctx, msg.RemoteUID, junk.FullPath); err != nil {
log.Printf("[rules] graph move: %v", err)
}
case "delete":
if err := gc.DeleteMessage(ctx, msg.RemoteUID); err != nil {
log.Printf("[rules] graph delete: %v", err)
}
case "mark_read":
if err := gc.MarkRead(ctx, msg.RemoteUID, true); err != nil {
log.Printf("[rules] graph mark_read: %v", err)
}
case "forward":
s.ruleForwardGraph(gc, account, msg, rule.ActionValue)
case "auto_reply":
s.ruleAutoReplyGraph(gc, account, msg, rule)
}
}
func (s *Scheduler) ruleForwardGraph(gc *graph.Client, account *models.EmailAccount, msg *models.Message, to string) {
req := &models.ComposeRequest{
AccountID: account.ID,
To: []string{to},
Subject: "Fwd: " + msg.Subject,
BodyHTML: msg.BodyHTML,
BodyText: msg.BodyText,
}
if err := gc.SendMail(context.Background(), req); err != nil {
log.Printf("[rules] graph forward to %s: %v", to, err)
}
}
func (s *Scheduler) ruleAutoReplyGraph(gc *graph.Client, account *models.EmailAccount, msg *models.Message, rule *models.Rule) {
recipient := msg.FromEmail
if recipient == "" {
return
}
if sent, err := s.db.HasRecentAutoReply(account.ID, rule.ID, recipient); err != nil || sent {
return
}
req := &models.ComposeRequest{
AccountID: account.ID,
To: []string{recipient},
Subject: rule.ActionValue,
BodyText: rule.ActionOptions.Body,
BodyHTML: rule.ActionOptions.Body,
}
if err := gc.SendMail(context.Background(), req); err != nil {
log.Printf("[rules] graph auto_reply to %s: %v", recipient, err)
return
}
s.db.LogAutoReply(account.ID, rule.ID, recipient)
}