first commit

This commit is contained in:
2026-08-09 18:03:09 +01:00
commit d7ca591b76
169 changed files with 51272 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
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
}
+131
View File
@@ -0,0 +1,131 @@
// Package sieve implements a Sieve (RFC 5228) interpreter covering the
// common mail-filtering subset: header tests (:contains, :is), if/elsif/else,
// and the fileinto/discard/keep/stop actions. Not full RFC 5228 — no
// extensions (vacation, reject, notify), no envelope/size/address tests,
// no allof/anyof boolean combinators. This covers what real users actually
// write for "move mail matching X to folder Y" / "discard mail from Z",
// which is the overwhelming majority of real-world Sieve scripts; broader
// grammar support is a natural follow-up once client compatibility testing
// calls for it.
package sieve
import (
"fmt"
"strings"
"unicode"
)
type tokenKind int
const (
tokIdent tokenKind = iota
tokString
tokTag // :contains, :is, etc.
tokSemicolon
tokLBrace
tokRBrace
tokEOF
)
type token struct {
kind tokenKind
value string
}
type lexer struct {
input []rune
pos int
}
func newLexer(script string) *lexer {
return &lexer{input: []rune(script)}
}
func (l *lexer) next() (token, error) {
l.skipWhitespaceAndComments()
if l.pos >= len(l.input) {
return token{kind: tokEOF}, nil
}
c := l.input[l.pos]
switch {
case c == ';':
l.pos++
return token{kind: tokSemicolon, value: ";"}, nil
case c == '{':
l.pos++
return token{kind: tokLBrace, value: "{"}, nil
case c == '}':
l.pos++
return token{kind: tokRBrace, value: "}"}, nil
case c == '"':
return l.readString()
case c == ':':
return l.readTag()
case unicode.IsLetter(c):
return l.readIdent()
default:
return token{}, fmt.Errorf("unexpected character %q at position %d", c, l.pos)
}
}
func (l *lexer) skipWhitespaceAndComments() {
for l.pos < len(l.input) {
c := l.input[l.pos]
if unicode.IsSpace(c) {
l.pos++
continue
}
// Single-line comment: # ... end of line
if c == '#' {
for l.pos < len(l.input) && l.input[l.pos] != '\n' {
l.pos++
}
continue
}
// Bracketed comment: /* ... */
if c == '/' && l.pos+1 < len(l.input) && l.input[l.pos+1] == '*' {
l.pos += 2
for l.pos+1 < len(l.input) && !(l.input[l.pos] == '*' && l.input[l.pos+1] == '/') {
l.pos++
}
l.pos += 2
continue
}
break
}
}
func (l *lexer) readString() (token, error) {
l.pos++ // skip opening quote
var sb strings.Builder
for l.pos < len(l.input) && l.input[l.pos] != '"' {
if l.input[l.pos] == '\\' && l.pos+1 < len(l.input) {
l.pos++
}
sb.WriteRune(l.input[l.pos])
l.pos++
}
if l.pos >= len(l.input) {
return token{}, fmt.Errorf("unterminated string literal")
}
l.pos++ // skip closing quote
return token{kind: tokString, value: sb.String()}, nil
}
func (l *lexer) readTag() (token, error) {
start := l.pos
l.pos++ // skip ':'
for l.pos < len(l.input) && (unicode.IsLetter(l.input[l.pos]) || l.input[l.pos] == '-') {
l.pos++
}
return token{kind: tokTag, value: string(l.input[start:l.pos])}, nil
}
func (l *lexer) readIdent() (token, error) {
start := l.pos
for l.pos < len(l.input) && (unicode.IsLetter(l.input[l.pos]) || unicode.IsDigit(l.input[l.pos]) || l.input[l.pos] == '_') {
l.pos++
}
return token{kind: tokIdent, value: string(l.input[start:l.pos])}, nil
}
+229
View File
@@ -0,0 +1,229 @@
package sieve
import "fmt"
// ── AST ───────────────────────────────────────────────────────────────────────
type Script struct {
Statements []Statement
}
// Statement is either an Action or an IfStatement.
type Statement interface{ isStatement() }
type Action struct {
Name string // "fileinto" | "discard" | "keep" | "stop"
Arg string // folder name for fileinto, empty otherwise
}
func (Action) isStatement() {}
type IfStatement struct {
Test Test
Then []Statement
ElseIfs []ElseIf
Else []Statement
HasElse bool
}
func (IfStatement) isStatement() {}
type ElseIf struct {
Test Test
Then []Statement
}
// Test is a condition — this pass supports only header tests, the
// overwhelming majority of real-world filtering rules.
type Test struct {
Kind string // "header" | "true"
MatchType string // "contains" | "is"
Header string
Value string
}
// ── Parser ────────────────────────────────────────────────────────────────────
type parser struct {
lex *lexer
cur token
}
func Parse(script string) (*Script, error) {
p := &parser{lex: newLexer(script)}
if err := p.advance(); err != nil {
return nil, err
}
s := &Script{}
for p.cur.kind != tokEOF {
stmt, err := p.parseStatement()
if err != nil {
return nil, err
}
s.Statements = append(s.Statements, stmt)
}
return s, nil
}
func (p *parser) advance() error {
t, err := p.lex.next()
if err != nil {
return err
}
p.cur = t
return nil
}
func (p *parser) expect(kind tokenKind, desc string) (token, error) {
if p.cur.kind != kind {
return token{}, fmt.Errorf("expected %s, got %q", desc, p.cur.value)
}
t := p.cur
if err := p.advance(); err != nil {
return token{}, err
}
return t, nil
}
func (p *parser) parseStatement() (Statement, error) {
if p.cur.kind != tokIdent {
return nil, fmt.Errorf("expected statement, got %q", p.cur.value)
}
switch p.cur.value {
case "if":
return p.parseIf()
case "fileinto":
if err := p.advance(); err != nil {
return nil, err
}
arg, err := p.expect(tokString, "folder name")
if err != nil {
return nil, err
}
if _, err := p.expect(tokSemicolon, ";"); err != nil {
return nil, err
}
return Action{Name: "fileinto", Arg: arg.value}, nil
case "discard", "keep", "stop":
name := p.cur.value
if err := p.advance(); err != nil {
return nil, err
}
if _, err := p.expect(tokSemicolon, ";"); err != nil {
return nil, err
}
return Action{Name: name}, nil
default:
return nil, fmt.Errorf("unsupported command %q", p.cur.value)
}
}
func (p *parser) parseIf() (Statement, error) {
if err := p.advance(); err != nil { // skip "if"
return nil, err
}
test, err := p.parseTest()
if err != nil {
return nil, err
}
then, err := p.parseBlock()
if err != nil {
return nil, err
}
stmt := IfStatement{Test: test, Then: then}
for p.cur.kind == tokIdent && p.cur.value == "elsif" {
if err := p.advance(); err != nil {
return nil, err
}
elifTest, err := p.parseTest()
if err != nil {
return nil, err
}
elifThen, err := p.parseBlock()
if err != nil {
return nil, err
}
stmt.ElseIfs = append(stmt.ElseIfs, ElseIf{Test: elifTest, Then: elifThen})
}
if p.cur.kind == tokIdent && p.cur.value == "else" {
if err := p.advance(); err != nil {
return nil, err
}
elseBlock, err := p.parseBlock()
if err != nil {
return nil, err
}
stmt.Else = elseBlock
stmt.HasElse = true
}
return stmt, nil
}
func (p *parser) parseTest() (Test, error) {
if p.cur.kind != tokIdent {
return Test{}, fmt.Errorf("expected test, got %q", p.cur.value)
}
if p.cur.value == "true" {
if err := p.advance(); err != nil {
return Test{}, err
}
return Test{Kind: "true"}, nil
}
if p.cur.value != "header" {
return Test{}, fmt.Errorf("unsupported test %q (only 'header' and 'true' supported)", p.cur.value)
}
if err := p.advance(); err != nil {
return Test{}, err
}
if p.cur.kind != tokTag {
return Test{}, fmt.Errorf("expected match type (:contains or :is), got %q", p.cur.value)
}
matchType := p.cur.value[1:] // strip leading ':'
if matchType != "contains" && matchType != "is" {
return Test{}, fmt.Errorf("unsupported match type %q (only :contains and :is supported)", matchType)
}
if err := p.advance(); err != nil {
return Test{}, err
}
headerTok, err := p.expect(tokString, "header name")
if err != nil {
return Test{}, err
}
valueTok, err := p.expect(tokString, "match value")
if err != nil {
return Test{}, err
}
return Test{Kind: "header", MatchType: matchType, Header: headerTok.value, Value: valueTok.value}, nil
}
func (p *parser) parseBlock() ([]Statement, error) {
if _, err := p.expect(tokLBrace, "{"); err != nil {
return nil, err
}
var stmts []Statement
for p.cur.kind != tokRBrace {
if p.cur.kind == tokEOF {
return nil, fmt.Errorf("unterminated block, expected }")
}
stmt, err := p.parseStatement()
if err != nil {
return nil, err
}
stmts = append(stmts, stmt)
}
if _, err := p.expect(tokRBrace, "}"); err != nil {
return nil, err
}
return stmts, nil
}
+32
View File
@@ -0,0 +1,32 @@
package sieve
import "testing"
func FuzzParse(f *testing.F) {
f.Add(`if header :contains "subject" "invoice" { fileinto "Invoices"; stop; }`)
f.Add(`if header :is "from" "boss@example.com" { fileinto "Important"; } elsif header :contains "subject" "urgent" { fileinto "Important"; } else { keep; }`)
f.Add("")
f.Add("keep;")
f.Add("if true { discard; }")
f.Add(`if header :contains "subject" { fileinto "X" }`)
f.Add("if header { }")
f.Add("{{{{{{{")
f.Add(`if header :contains "a" "b`)
f.Add("if header :bogus \"x\" \"y\" { keep; }")
f.Add("fileinto;")
f.Fuzz(func(t *testing.T, data string) {
// This is the fuzz target most directly exposed to untrusted input
// in production — every ManageSieve PUTSCRIPT is parsed by this
// exact function before storage. A crash here would be a remotely
// triggerable DoS against an authenticated user's own session, so
// "never panics" matters more here than for the calendar/contact
// parsers.
defer func() {
if r := recover(); r != nil {
t.Fatalf("Parse panicked on input %q: %v", data, r)
}
}()
Parse(data)
})
}