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>
This commit is contained in:
1 parent
f179cb1f27
commit
b9ebf606e1
5 files changed
+1054
No files matched your search
@@ -0,0 +1,320 @@
|
||||
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
|
||||
Reference in new issue
Block a user