Files
gomail/internal/sieve/interp.go
T
2026-08-09 18:03:09 +01:00

106 lines
3.2 KiB
Go

package sieve
import "strings"
// Result is the outcome of running a script against one message.
type Result struct {
Action string // "fileinto" | "discard" | "keep" (default if nothing else fired)
Folder string // set only when Action == "fileinto"
}
// Execute runs script against the given headers (case-insensitive header
// names, matching real email header semantics) and returns the first
// decisive action encountered. "stop" halts execution immediately with
// whatever result has accumulated so far. If no action fires, the default
// result is "keep" (deliver to INBOX), matching RFC 5228 §2.10's implicit
// keep behavior.
//
// Simplification: real Sieve treats keep/fileinto/discard as an
// accumulating SET of actions (a message can be filed into a folder AND
// kept in INBOX, for instance) — this implementation tracks only the single
// most recent action instead, last-one-wins. This still matches RFC 5228's
// core rule that "discard cancels the implicit keep, but an explicit keep
// after it still delivers" (§4.4) — a discard followed by an unconditional
// keep with no stop in between DOES deliver the message, correctly. What's
// NOT supported is a script that intends both fileinto AND keep to fire
// simultaneously (message copied to a folder AND left in INBOX) — write
// "stop;" after the decisive action if that's not the intended behavior,
// same as real Sieve authors are advised to do to avoid ambiguity.
func Execute(script *Script, headers map[string]string) Result {
result := Result{Action: "keep"}
execStatements(script.Statements, headers, &result)
return result
}
// execStatements returns true if execution should stop (a "stop" action fired).
func execStatements(stmts []Statement, headers map[string]string, result *Result) bool {
for _, stmt := range stmts {
switch s := stmt.(type) {
case Action:
switch s.Name {
case "fileinto":
result.Action = "fileinto"
result.Folder = s.Arg
case "discard":
result.Action = "discard"
case "keep":
result.Action = "keep"
case "stop":
return true
}
case IfStatement:
if evalTest(s.Test, headers) {
if execStatements(s.Then, headers, result) {
return true
}
continue
}
matched := false
for _, ei := range s.ElseIfs {
if evalTest(ei.Test, headers) {
matched = true
if execStatements(ei.Then, headers, result) {
return true
}
break
}
}
if !matched && s.HasElse {
if execStatements(s.Else, headers, result) {
return true
}
}
}
}
return false
}
func evalTest(t Test, headers map[string]string) bool {
switch t.Kind {
case "true":
return true
case "header":
actual, ok := lookupHeader(headers, t.Header)
if !ok {
return false
}
switch t.MatchType {
case "contains":
return strings.Contains(strings.ToLower(actual), strings.ToLower(t.Value))
case "is":
return strings.EqualFold(strings.TrimSpace(actual), strings.TrimSpace(t.Value))
}
}
return false
}
func lookupHeader(headers map[string]string, name string) (string, bool) {
// Case-insensitive lookup — email headers are case-insensitive per RFC 5322.
for k, v := range headers {
if strings.EqualFold(k, name) {
return v, true
}
}
return "", false
}