Files
LambdaMail/services/protocols/internal/domain/sieve/sieve.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

105 lines
3.2 KiB
Go

// Package sieve evaluates the mail-filtering rules a mailbox owner writes.
//
// This is a deliberate subset of RFC 5228 plus the vacation extension (RFC
// 5230): the tests and actions the webmail generates and that a hand-written
// script is likely to use. It is not a complete Sieve implementation, and it
// says so by refusing what it does not understand rather than guessing.
//
// It exists because nothing ran these rules at all. ManageSieve stored
// scripts and the settings screen wrote them, but the delivery path never
// consulted either - so a vacation responder that was switched on never
// replied, and a filter that said "file this in Faturas" filed nothing.
package sieve
import (
"fmt"
"strings"
)
// maxRules bounds a script. A mailbox's rules are written by hand; thousands
// of them are not a filing policy, they are a way to make every delivery slow.
const maxRules = 500
// Message is the part of an arriving message the rules can see.
type Message struct {
// Headers are keyed by lowercased name, since header names are
// case-insensitive and the script may spell one any way it likes.
Headers map[string][]string
// Sender is the envelope sender. Empty means a bounce, which nothing may
// auto-reply to.
Sender string
// Recipient is the mailbox this copy is being delivered to.
Recipient string
}
func (m Message) header(name string) []string {
if m.Headers == nil {
return nil
}
return m.Headers[strings.ToLower(strings.TrimSpace(name))]
}
// VacationReply is an out-of-office response the script asked for.
type VacationReply struct {
Subject string
Body string
}
// Outcome is what the rules decided about one message.
type Outcome struct {
// Folder is where to file it, or empty to leave the delivery path's own
// choice alone.
Folder string
// Discard drops the message. It is still accepted over SMTP: refusing it
// would tell the sender their mail bounced, which is not what a filing
// rule means.
Discard bool
Flags []string
// Vacation is the reply to send, or nil.
Vacation *VacationReply
}
// Script is a parsed set of rules.
type Script struct {
statements []statement
}
// Parse reads a script, or reports why it cannot.
//
// A script that does not parse must never be treated as an empty one: silently
// ignoring a rule the user wrote is worse than telling them it is broken.
func Parse(source string) (*Script, error) {
lines := splitStatements(source)
if len(lines) > maxRules {
return nil, fmt.Errorf("sieve: script has %d statements, more than the %d allowed", len(lines), maxRules)
}
statements, rest, err := parseBlock(lines, 0)
if err != nil {
return nil, err
}
if rest != len(lines) {
return nil, fmt.Errorf("sieve: unexpected %q", lines[rest])
}
return &Script{statements: statements}, nil
}
// Evaluate applies a parsed script to one message.
func Evaluate(script *Script, msg Message) Outcome {
out := Outcome{}
if script == nil {
return out
}
run(script.statements, msg, &out)
return out
}
// run walks a block, stopping at the first `stop`.
func run(statements []statement, msg Message, out *Outcome) bool {
for _, st := range statements {
if st.stopped(msg, out) {
return true
}
}
return false
}