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>
321 lines
7.3 KiB
Go
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
|