Regras. A tela pedia um tipo de correspondência do Sieve pelo nome - :contains, :is, :matches (regex) - e colava o valor escrito diretamente num script. Punha uma linguagem de padrões à frente de quem só queria dizer "o que vem do banco vai para Faturas", e bastava uma aspa no valor para o script deixar de fazer parse, desativando silenciosamente todas as regras de uma vez. O vocabulário passa a ser o de quem escreve: um campo, uma condição em português, um valor e uma ação. Os curingas são gerados a partir de "começa com" e "termina com" - ninguém os escreve, e um asterisco escrito por acaso continua a ser um asterisco. A pasta de destino é escolhida de uma lista das pastas que existem mesmo, em vez de ser escrita à mão: antes uma regra podia nomear uma pasta inexistente e nunca disparar, sem dizer porquê. A ordem passa a ser visível e alterável, porque a primeira regra que arquiva uma mensagem decide para onde ela vai. Um script que esta tela não consegue exprimir - escrito à mão, ou por um cliente de secretária sobre ManageSieve - é mostrado tal como está, em vez de ser reescrito na coisa mais parecida que este formulário sabe dizer, o que mudaria em silêncio o que o correio de alguém faz. Pastas próprias. Existiam na base de dados e as regras podiam arquivar nelas, mas a barra lateral listava só as seis fixas, portanto não havia como ver uma - nem como criar. Criar fica onde a vontade aparece, na própria barra; apagar avisa que leva as mensagens junto, porque leva. Encontrado a escrever isto: o motor tratava "\\*" como barra seguida de curinga em vez de um asterisco literal. Como é esta tela que gera os curingas, uma regra sobre um assunto com "50% *" teria correspondido a todos os assuntos começados por "50% ". Corrigido no motor, com testes dos dois lados. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
5.9 KiB
Go
241 lines
5.9 KiB
Go
package sieve
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// test is a condition a rule asks about a message.
|
|
type test interface {
|
|
matches(msg Message) bool
|
|
}
|
|
|
|
// headerTest is `header :comparator "Name" "value"`.
|
|
type headerTest struct {
|
|
name string
|
|
value string
|
|
comparator string
|
|
}
|
|
|
|
func (h headerTest) matches(msg Message) bool {
|
|
for _, actual := range msg.header(h.name) {
|
|
if compare(h.comparator, actual, h.value) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type anyOf struct{ tests []test }
|
|
|
|
func (a anyOf) matches(msg Message) bool {
|
|
for _, t := range a.tests {
|
|
if t.matches(msg) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type allOf struct{ tests []test }
|
|
|
|
func (a allOf) matches(msg Message) bool {
|
|
for _, t := range a.tests {
|
|
if !t.matches(msg) {
|
|
return false
|
|
}
|
|
}
|
|
return len(a.tests) > 0
|
|
}
|
|
|
|
type notTest struct{ inner test }
|
|
|
|
func (n notTest) matches(msg Message) bool { return !n.inner.matches(msg) }
|
|
|
|
// compare applies one of Sieve's match types.
|
|
//
|
|
// All of them ignore case. Sieve's default comparator is
|
|
// "i;ascii-casemap", and for mail rules it is also what a person means: a
|
|
// subject is the same subject however it was capitalised.
|
|
func compare(comparator, actual, want string) bool {
|
|
a := strings.ToLower(strings.TrimSpace(actual))
|
|
w := strings.ToLower(want)
|
|
switch comparator {
|
|
case "is":
|
|
return a == w
|
|
case "matches":
|
|
return wildcardMatch(a, w)
|
|
default: // contains
|
|
return strings.Contains(a, w)
|
|
}
|
|
}
|
|
|
|
// wildcardMatch implements Sieve's :matches, where * is any run of characters
|
|
// and ? is exactly one.
|
|
//
|
|
// Deliberately not a regular expression: in Sieve every other character is a
|
|
// literal, so a pattern like "a.b@example.test" must match only a real dot.
|
|
// Compiling it as a regex would quietly match "axb@example.test" too - and
|
|
// would hand a script author the entire regex grammar, including patterns
|
|
// that take exponential time on a crafted subject line.
|
|
func wildcardMatch(value, pattern string) bool {
|
|
v, p := []rune(value), []rune(pattern)
|
|
|
|
// literalAt reports whether the pattern rune at i is escaped, and how wide
|
|
// the escape is. RFC 5228 section 2.7.1: a backslash makes the next
|
|
// character literal, which is how a rule can be about a real asterisk.
|
|
// The rules screen relies on this - it escapes any wildcard the user typed
|
|
// so it stays the character they meant.
|
|
escaped := func(i int) (rune, int, bool) {
|
|
if p[i] == '\\' && i+1 < len(p) {
|
|
return p[i+1], 2, true
|
|
}
|
|
return p[i], 1, false
|
|
}
|
|
|
|
// Iterative backtracking: linear in the common case, and it cannot blow
|
|
// the stack on a long subject the way a recursive version can.
|
|
vi, pi := 0, 0
|
|
star, mark := -1, 0
|
|
for vi < len(v) {
|
|
if pi < len(p) {
|
|
ch, width, isEscaped := escaped(pi)
|
|
switch {
|
|
case isEscaped && ch == v[vi]:
|
|
vi++
|
|
pi += width
|
|
continue
|
|
case !isEscaped && (ch == '?' || ch == v[vi]):
|
|
vi++
|
|
pi += width
|
|
continue
|
|
case !isEscaped && ch == '*':
|
|
star = pi
|
|
mark = vi
|
|
pi += width
|
|
continue
|
|
}
|
|
}
|
|
if star >= 0 {
|
|
pi = star + 1
|
|
mark++
|
|
vi = mark
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
for pi < len(p) {
|
|
ch, width, isEscaped := escaped(pi)
|
|
if isEscaped || ch != '*' {
|
|
break
|
|
}
|
|
pi += width
|
|
}
|
|
return pi == len(p)
|
|
}
|
|
|
|
// parseTest reads the condition of an if/elsif.
|
|
func parseTest(source string) (test, error) {
|
|
source = strings.TrimSpace(source)
|
|
lower := strings.ToLower(source)
|
|
|
|
switch {
|
|
case strings.HasPrefix(lower, "not "):
|
|
inner, err := parseTest(source[len("not "):])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return notTest{inner: inner}, nil
|
|
|
|
case strings.HasPrefix(lower, "anyof"), strings.HasPrefix(lower, "allof"):
|
|
inner, err := parseTestList(source[len("anyof"):])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.HasPrefix(lower, "anyof") {
|
|
return anyOf{tests: inner}, nil
|
|
}
|
|
return allOf{tests: inner}, nil
|
|
|
|
case strings.HasPrefix(lower, "header"), strings.HasPrefix(lower, "address"):
|
|
// address is treated as header here: the fields a rule asks about -
|
|
// From, To, Cc - carry the address in the header anyway, and matching
|
|
// the whole header is the more forgiving of the two.
|
|
return parseHeaderTest(source)
|
|
|
|
case lower == "true":
|
|
return allOf{tests: []test{}}, nil
|
|
}
|
|
return nil, fmt.Errorf("sieve: unsupported test %q", firstWord(source))
|
|
}
|
|
|
|
// parseTestList splits "(a, b, c)" into its tests, respecting quotes and
|
|
// nesting so a comma inside a value does not split the list.
|
|
func parseTestList(source string) ([]test, error) {
|
|
source = strings.TrimSpace(source)
|
|
source = strings.TrimPrefix(source, "(")
|
|
source = strings.TrimSuffix(strings.TrimSpace(source), ")")
|
|
|
|
var parts []string
|
|
var current strings.Builder
|
|
depth, inString := 0, false
|
|
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 == '(':
|
|
depth++
|
|
current.WriteByte(c)
|
|
case c == ')':
|
|
depth--
|
|
current.WriteByte(c)
|
|
case c == ',' && depth == 0:
|
|
parts = append(parts, current.String())
|
|
current.Reset()
|
|
default:
|
|
current.WriteByte(c)
|
|
}
|
|
}
|
|
if strings.TrimSpace(current.String()) != "" {
|
|
parts = append(parts, current.String())
|
|
}
|
|
|
|
out := make([]test, 0, len(parts))
|
|
for _, part := range parts {
|
|
parsed, err := parseTest(part)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, parsed)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, fmt.Errorf("sieve: empty test list")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func parseHeaderTest(source string) (test, error) {
|
|
comparator := "contains"
|
|
for _, candidate := range []string{"contains", "is", "matches"} {
|
|
if strings.Contains(strings.ToLower(source), ":"+candidate) {
|
|
comparator = candidate
|
|
break
|
|
}
|
|
}
|
|
values := quotedStrings(source)
|
|
if len(values) < 2 {
|
|
return nil, fmt.Errorf("sieve: header test needs a name and a value")
|
|
}
|
|
return headerTest{name: values[0], value: values[1], comparator: comparator}, nil
|
|
}
|