Files
LambdaMail/services/protocols/internal/domain/sieve/parser.go
T
Lucas RayanandClaude Opus 5 b9ebf606e1 feat(sieve): motor que avalia as regras na entrega
Primeira metade do que falta para a ausência e os filtros funcionarem:
o motor em si, como domínio puro e testável, ainda sem estar ligado à
entrega.

É um subconjunto deliberado do RFC 5228 mais o vacation do RFC 5230:
os testes e as ações que a interface gera e que um script escrito à
mão provavelmente usa - header com :is, :contains e :matches, anyof,
allof, not, if/elsif/else, fileinto, discard, addflag, keep, stop e
vacation. O que não entende é recusado em vez de ignorado: uma regra
que o utilizador escreveu e que silenciosamente não faz nada é pior do
que uma que diz que não consegue correr.

:matches é correspondência com curingas, não expressão regular. Em
Sieve todos os outros caracteres são literais, portanto "a.b@x.test"
tem de casar apenas com um ponto verdadeiro; compilá-lo como regex
faria casar também "axb@x.test" e entregaria a um autor de scripts toda
a gramática das expressões regulares, incluindo padrões que levam tempo
exponencial numa linha de assunto construída para isso. O algoritmo faz
backtracking iterativo, linear no caso comum, e há um teste com limite
de tempo que falha se voltar a ser exponencial.

A parte da ausência que realmente importa é quando não responder. O
RFC 3834 é a regra: responde-se à mensagem de uma pessoa e a mais nada.
Remetente de envelope vazio, auto-submitted, precedence bulk, qualquer
cabeçalho de lista, x-auto-response-suppress, endereços reservados como
mailer-daemon, e a própria caixa - nenhum recebe resposta. É assim que
uma caixa evita responder a uma lista à frente de toda a gente, ou
entrar num ciclo com outro respondedor automático.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:24:20 -03:00

321 lines
7.3 KiB
Go

package sieve
import (
"fmt"
"strconv"
"strings"
)
// statement is one thing a script does.
type statement interface {
// stopped reports whether evaluation must end here.
stopped(msg Message, out *Outcome) bool
}
// --- statements ----------------------------------------------------------
type conditional struct {
branches []branch
fallback []statement
}
type branch struct {
test test
body []statement
}
func (c conditional) stopped(msg Message, out *Outcome) bool {
for _, b := range c.branches {
if b.test.matches(msg) {
return run(b.body, msg, out)
}
}
return run(c.fallback, msg, out)
}
type fileInto struct{ folder string }
func (f fileInto) stopped(_ Message, out *Outcome) bool {
// The first rule to file the message decides. Letting a later rule
// overwrite it would make two rules silently disagree about one message.
if out.Folder == "" {
out.Folder = f.folder
}
return false
}
type discard struct{}
func (discard) stopped(_ Message, out *Outcome) bool {
out.Discard = true
return false
}
type addFlag struct{ flag string }
func (a addFlag) stopped(_ Message, out *Outcome) bool {
for _, existing := range out.Flags {
if existing == a.flag {
return false
}
}
out.Flags = append(out.Flags, a.flag)
return false
}
type keep struct{}
func (keep) stopped(_ Message, _ *Outcome) bool { return false }
type stop struct{}
func (stop) stopped(_ Message, _ *Outcome) bool { return true }
type vacation struct {
subject string
body string
}
func (v vacation) stopped(msg Message, out *Outcome) bool {
if !shouldAutoReply(msg) {
return false
}
out.Vacation = &VacationReply{Subject: v.subject, Body: v.body}
return false
}
// --- parsing -------------------------------------------------------------
// splitStatements flattens a script into statement-sized pieces, dropping
// comments and blank lines and keeping braces as their own tokens so blocks
// can be found without a full tokeniser.
func splitStatements(source string) []string {
var out []string
var current strings.Builder
inString := false
flush := func() {
text := strings.TrimSpace(current.String())
if text != "" {
out = append(out, text)
}
current.Reset()
}
for i := 0; i < len(source); i++ {
c := source[i]
switch {
case inString:
current.WriteByte(c)
if c == '\\' && i+1 < len(source) {
i++
current.WriteByte(source[i])
} else if c == '"' {
inString = false
}
case c == '"':
inString = true
current.WriteByte(c)
case c == '#':
// Comment to end of line.
for i < len(source) && source[i] != '\n' {
i++
}
case c == '{' || c == '}':
flush()
out = append(out, string(c))
case c == ';':
flush()
default:
current.WriteByte(c)
}
}
flush()
return out
}
// parseBlock reads statements until the block ends.
func parseBlock(lines []string, i int) ([]statement, int, error) {
var out []statement
for i < len(lines) {
line := lines[i]
if line == "}" {
return out, i, nil
}
st, next, err := parseStatement(lines, i)
if err != nil {
return nil, 0, err
}
if st != nil {
out = append(out, st)
}
i = next
}
return out, i, nil
}
func parseStatement(lines []string, i int) (statement, int, error) {
line := lines[i]
head := strings.ToLower(firstWord(line))
switch head {
case "require":
// Capabilities are declarative; what this engine supports is decided
// by what it can parse below, so the line is accepted and skipped.
return nil, i + 1, nil
case "if":
return parseConditional(lines, i)
case "fileinto":
folder, err := singleString(line, "fileinto")
if err != nil {
return nil, 0, err
}
return fileInto{folder: folder}, i + 1, nil
case "addflag", "setflag":
flag, err := singleString(line, head)
if err != nil {
return nil, 0, err
}
return addFlag{flag: flag}, i + 1, nil
case "discard":
return discard{}, i + 1, nil
case "keep":
return keep{}, i + 1, nil
case "stop":
return stop{}, i + 1, nil
case "vacation":
v, err := parseVacation(line)
if err != nil {
return nil, 0, err
}
return v, i + 1, nil
case "":
return nil, i + 1, nil
default:
// Refused rather than ignored: a rule the user wrote that quietly does
// nothing is worse than one that reports it cannot run.
return nil, 0, fmt.Errorf("sieve: unsupported statement %q", firstWord(line))
}
}
func parseConditional(lines []string, i int) (statement, int, error) {
cond := conditional{}
for i < len(lines) {
line := lines[i]
head := strings.ToLower(firstWord(line))
if head != "if" && head != "elsif" && head != "else" {
break
}
if head == "else" {
body, next, err := readBraced(lines, i+1)
if err != nil {
return nil, 0, err
}
cond.fallback = body
i = next
break
}
testSource := strings.TrimSpace(line[len(head):])
parsedTest, err := parseTest(testSource)
if err != nil {
return nil, 0, err
}
body, next, err := readBraced(lines, i+1)
if err != nil {
return nil, 0, err
}
cond.branches = append(cond.branches, branch{test: parsedTest, body: body})
i = next
if len(cond.branches) > maxRules {
return nil, 0, fmt.Errorf("sieve: too many branches")
}
}
if len(cond.branches) == 0 {
return nil, 0, fmt.Errorf("sieve: if without a test")
}
return cond, i, nil
}
// readBraced consumes "{ ... }" starting at i.
func readBraced(lines []string, i int) ([]statement, int, error) {
if i >= len(lines) || lines[i] != "{" {
return nil, 0, fmt.Errorf("sieve: expected a block")
}
body, next, err := parseBlock(lines, i+1)
if err != nil {
return nil, 0, err
}
if next >= len(lines) || lines[next] != "}" {
return nil, 0, fmt.Errorf("sieve: unclosed block")
}
return body, next + 1, nil
}
func firstWord(line string) string {
for i := 0; i < len(line); i++ {
if line[i] == ' ' || line[i] == '\t' || line[i] == '(' {
return line[:i]
}
}
return line
}
// quotedStrings pulls every "..." out of a fragment, honouring escapes.
func quotedStrings(fragment string) []string {
var out []string
for i := 0; i < len(fragment); i++ {
if fragment[i] != '"' {
continue
}
var b strings.Builder
i++
for i < len(fragment) && fragment[i] != '"' {
if fragment[i] == '\\' && i+1 < len(fragment) {
i++
// \\ and \" are the escapes Sieve defines; anything else keeps
// the backslash, which is what makes "\\Flagged" survive.
switch fragment[i] {
case '"', '\\':
b.WriteByte(fragment[i])
default:
b.WriteByte('\\')
b.WriteByte(fragment[i])
}
} else {
b.WriteByte(fragment[i])
}
i++
}
out = append(out, b.String())
}
return out
}
func singleString(line, keyword string) (string, error) {
values := quotedStrings(strings.TrimSpace(line[len(keyword):]))
if len(values) != 1 {
return "", fmt.Errorf("sieve: %s needs exactly one quoted value", keyword)
}
return values[0], nil
}
func parseVacation(line string) (statement, error) {
rest := strings.TrimSpace(line[len("vacation"):])
values := quotedStrings(rest)
if len(values) == 0 {
return nil, fmt.Errorf("sieve: vacation needs a message")
}
// ":subject" takes the first quoted value; the reason is the last.
if strings.Contains(strings.ToLower(rest), ":subject") && len(values) >= 2 {
return vacation{subject: values[0], body: values[len(values)-1]}, nil
}
return vacation{body: values[len(values)-1]}, nil
}
var _ = strconv.Quote