From b9ebf606e194b9cc24200babaa6e734dcb7d7533 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sat, 22 Aug 2026 13:24:20 -0300 Subject: [PATCH] feat(sieve): motor que avalia as regras na entrega MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../protocols/internal/domain/sieve/parser.go | 320 ++++++++++++++++ .../protocols/internal/domain/sieve/sieve.go | 104 +++++ .../internal/domain/sieve/sieve_test.go | 357 ++++++++++++++++++ .../protocols/internal/domain/sieve/test.go | 213 +++++++++++ .../internal/domain/sieve/vacation.go | 60 +++ 5 files changed, 1054 insertions(+) create mode 100644 services/protocols/internal/domain/sieve/parser.go create mode 100644 services/protocols/internal/domain/sieve/sieve.go create mode 100644 services/protocols/internal/domain/sieve/sieve_test.go create mode 100644 services/protocols/internal/domain/sieve/test.go create mode 100644 services/protocols/internal/domain/sieve/vacation.go diff --git a/services/protocols/internal/domain/sieve/parser.go b/services/protocols/internal/domain/sieve/parser.go new file mode 100644 index 0000000..c31d754 --- /dev/null +++ b/services/protocols/internal/domain/sieve/parser.go @@ -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 diff --git a/services/protocols/internal/domain/sieve/sieve.go b/services/protocols/internal/domain/sieve/sieve.go new file mode 100644 index 0000000..2c547a3 --- /dev/null +++ b/services/protocols/internal/domain/sieve/sieve.go @@ -0,0 +1,104 @@ +// 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 +} diff --git a/services/protocols/internal/domain/sieve/sieve_test.go b/services/protocols/internal/domain/sieve/sieve_test.go new file mode 100644 index 0000000..ef51803 --- /dev/null +++ b/services/protocols/internal/domain/sieve/sieve_test.go @@ -0,0 +1,357 @@ +package sieve + +import ( + "strings" + "testing" + "time" +) + +// The rules a mailbox owner writes, actually applied to the mail that arrives. +// +// Until now nothing ran them. ManageSieve stored scripts, the settings screen +// wrote them, and every message was delivered as though no rule existed - so a +// vacation responder that was switched on never replied, and a filter that +// said "file this in Faturas" never filed anything anywhere. + +func evaluate(t *testing.T, script string, msg Message) Outcome { + t.Helper() + parsed, err := Parse(script) + if err != nil { + t.Fatalf("parse: %v\nscript:\n%s", err, script) + } + return Evaluate(parsed, msg) +} + +// The exact shape the settings screen writes. +const uiRule = `if header :contains "Subject" "Fatura" { fileinto "Faturas"; }` + +func TestRuleFilesTheMessageInItsFolder(t *testing.T) { + out := evaluate(t, uiRule, Message{Headers: map[string][]string{ + "subject": {"Fatura de agosto"}, + }}) + if out.Folder != "Faturas" { + t.Errorf("filed into %q, want Faturas", out.Folder) + } + if out.Discard { + t.Error("the message was discarded") + } +} + +func TestRuleThatDoesNotMatchLeavesTheMessageAlone(t *testing.T) { + out := evaluate(t, uiRule, Message{Headers: map[string][]string{ + "subject": {"Almoço amanhã"}, + }}) + if out.Folder != "" { + t.Errorf("an unmatched rule filed into %q", out.Folder) + } +} + +// Header matching is case-insensitive on the name, per RFC 5228: a header is +// "Subject" or "subject" depending on who sent the mail. +func TestHeaderNameIsCaseInsensitive(t *testing.T) { + out := evaluate(t, `if header :contains "SUBJECT" "fatura" { fileinto "Faturas"; }`, + Message{Headers: map[string][]string{"subject": {"Fatura de agosto"}}}) + if out.Folder != "Faturas" { + t.Errorf("folder %q", out.Folder) + } +} + +func TestContainsIsCaseInsensitiveOnTheValue(t *testing.T) { + out := evaluate(t, uiRule, Message{Headers: map[string][]string{ + "subject": {"FATURA DE AGOSTO"}, + }}) + if out.Folder != "Faturas" { + t.Errorf("folder %q; :contains should not depend on case", out.Folder) + } +} + +func TestIsRequiresTheWholeValue(t *testing.T) { + script := `if header :is "Subject" "Fatura" { fileinto "Faturas"; }` + if out := evaluate(t, script, Message{Headers: map[string][]string{"subject": {"Fatura"}}}); out.Folder != "Faturas" { + t.Errorf("exact match failed: %q", out.Folder) + } + if out := evaluate(t, script, Message{Headers: map[string][]string{"subject": {"Fatura de agosto"}}}); out.Folder != "" { + t.Errorf(":is matched a longer value: %q", out.Folder) + } +} + +// :matches is Sieve's wildcard form - * and ? - and is what the interface +// should offer instead of asking a human for a regular expression. +func TestMatchesUsesWildcardsNotRegex(t *testing.T) { + script := `if header :matches "From" "*@google.com" { fileinto "Relatórios"; }` + if out := evaluate(t, script, Message{Headers: map[string][]string{ + "from": {"noreply-dmarc-support@google.com"}, + }}); out.Folder != "Relatórios" { + t.Errorf("wildcard did not match: %q", out.Folder) + } + if out := evaluate(t, script, Message{Headers: map[string][]string{ + "from": {"someone@example.test"}, + }}); out.Folder != "" { + t.Errorf("wildcard matched the wrong sender: %q", out.Folder) + } +} + +// A wildcard pattern must not be read as a regular expression: "." is a +// literal dot in Sieve, and treating it as "any character" would file mail +// the reader never asked to file. +func TestWildcardDotIsLiteral(t *testing.T) { + out := evaluate(t, `if header :matches "From" "a.b@example.test" { fileinto "X"; }`, + Message{Headers: map[string][]string{"from": {"axb@example.test"}}}) + if out.Folder != "" { + t.Errorf("the dot was treated as a regex wildcard: %q", out.Folder) + } +} + +func TestAnyofMatchesWhenOneTestPasses(t *testing.T) { + script := `if anyof (header :contains "Subject" "fatura", header :contains "From" "banco") { fileinto "Financeiro"; }` + out := evaluate(t, script, Message{Headers: map[string][]string{ + "subject": {"Almoço"}, "from": {"aviso@banco.test"}, + }}) + if out.Folder != "Financeiro" { + t.Errorf("anyof did not match: %q", out.Folder) + } +} + +func TestAllofRequiresEveryTest(t *testing.T) { + script := `if allof (header :contains "Subject" "fatura", header :contains "From" "banco") { fileinto "Financeiro"; }` + if out := evaluate(t, script, Message{Headers: map[string][]string{ + "subject": {"Fatura"}, "from": {"aviso@banco.test"}, + }}); out.Folder != "Financeiro" { + t.Errorf("allof with both matching: %q", out.Folder) + } + if out := evaluate(t, script, Message{Headers: map[string][]string{ + "subject": {"Fatura"}, "from": {"amigo@example.test"}, + }}); out.Folder != "" { + t.Errorf("allof matched with only one test true: %q", out.Folder) + } +} + +func TestElsifAndElse(t *testing.T) { + script := ` +if header :contains "Subject" "fatura" { fileinto "Financeiro"; } +elsif header :contains "Subject" "convite" { fileinto "Eventos"; } +else { fileinto "Outros"; }` + + cases := map[string]string{ + "Fatura de agosto": "Financeiro", + "Convite de festa": "Eventos", + "Qualquer coisa": "Outros", + } + for subject, want := range cases { + out := evaluate(t, script, Message{Headers: map[string][]string{"subject": {subject}}}) + if out.Folder != want { + t.Errorf("%q filed into %q, want %q", subject, out.Folder, want) + } + } +} + +// The first rule that files the message wins, so two rules cannot disagree +// about where one message ends up. +func TestFirstMatchingRuleDecidesTheFolder(t *testing.T) { + script := ` +if header :contains "Subject" "fatura" { fileinto "Financeiro"; } +if header :contains "Subject" "agosto" { fileinto "Arquivo"; }` + out := evaluate(t, script, Message{Headers: map[string][]string{"subject": {"Fatura de agosto"}}}) + if out.Folder != "Financeiro" { + t.Errorf("folder %q, want the first rule's Financeiro", out.Folder) + } +} + +func TestStopEndsEvaluation(t *testing.T) { + script := ` +if header :contains "Subject" "fatura" { fileinto "Financeiro"; stop; } +if header :contains "Subject" "agosto" { discard; }` + out := evaluate(t, script, Message{Headers: map[string][]string{"subject": {"Fatura de agosto"}}}) + if out.Discard { + t.Error("evaluation continued past stop and discarded the message") + } +} + +func TestDiscardIsHonoured(t *testing.T) { + out := evaluate(t, `if header :contains "Subject" "spam" { discard; }`, + Message{Headers: map[string][]string{"subject": {"spam barato"}}}) + if !out.Discard { + t.Error("discard was ignored") + } +} + +func TestAddflagMarksTheMessage(t *testing.T) { + out := evaluate(t, `if header :contains "From" "chefe" { addflag "\\Flagged"; }`, + Message{Headers: map[string][]string{"from": {"chefe@example.test"}}}) + if len(out.Flags) != 1 || out.Flags[0] != `\Flagged` { + t.Errorf("flags %v", out.Flags) + } +} + +// --- vacation ------------------------------------------------------------ + +const vacationScript = `require ["vacation"]; +vacation :subject "Fora do escritório" "Estou de férias até dia 30.";` + +func TestVacationProducesAReply(t *testing.T) { + out := evaluate(t, vacationScript, Message{ + Headers: map[string][]string{"from": {"amigo@example.test"}, "subject": {"Oi"}}, + Sender: "amigo@example.test", + }) + if out.Vacation == nil { + t.Fatal("no vacation reply was produced") + } + if out.Vacation.Subject != "Fora do escritório" { + t.Errorf("subject %q", out.Vacation.Subject) + } + if !strings.Contains(out.Vacation.Body, "férias") { + t.Errorf("body %q", out.Vacation.Body) + } +} + +// A vacation reply to a mailing list, a bounce or another autoresponder is how +// two servers end up replying to each other forever. RFC 3834 says an +// automatic reply goes only to a real person's message. +func TestVacationStaysSilentForAutomatedMail(t *testing.T) { + quiet := []Message{ + {Sender: "", Headers: map[string][]string{}}, // bounce: empty envelope sender + {Sender: "a@b.test", Headers: map[string][]string{"auto-submitted": {"auto-replied"}}}, + {Sender: "a@b.test", Headers: map[string][]string{"precedence": {"bulk"}}}, + {Sender: "a@b.test", Headers: map[string][]string{"list-id": {""}}}, + {Sender: "a@b.test", Headers: map[string][]string{"x-auto-response-suppress": {"All"}}}, + {Sender: "MAILER-DAEMON@b.test", Headers: map[string][]string{}}, + } + for i, msg := range quiet { + if out := evaluate(t, vacationScript, msg); out.Vacation != nil { + t.Errorf("case %d: replied to automated mail", i) + } + } +} + +func TestVacationDoesNotReplyToYourself(t *testing.T) { + out := evaluate(t, vacationScript, Message{ + Sender: "me@example.test", + Recipient: "me@example.test", + Headers: map[string][]string{"from": {"me@example.test"}}, + }) + if out.Vacation != nil { + t.Error("the responder replied to the mailbox's own message") + } +} + +// --- robustness ---------------------------------------------------------- + +// A script this parser cannot read must not stop the mail. Delivery is the +// thing that matters; a rule that does not run is a lesser failure than a +// message that never arrives. +func TestUnparseableScriptIsReportedNotFatal(t *testing.T) { + if _, err := Parse(`if header :contains "Subject" {{{`); err == nil { + t.Error("a malformed script should be reported as an error") + } +} + +func TestEmptyScriptDoesNothing(t *testing.T) { + out := evaluate(t, "", Message{Headers: map[string][]string{"subject": {"x"}}}) + if out.Folder != "" || out.Discard || out.Vacation != nil { + t.Errorf("an empty script did something: %+v", out) + } +} + +func TestCommentsAndBlankLinesAreIgnored(t *testing.T) { + script := ` +# uma regra qualquer +if header :contains "Subject" "fatura" { fileinto "Financeiro"; } # fim +` + out := evaluate(t, script, Message{Headers: map[string][]string{"subject": {"Fatura"}}}) + if out.Folder != "Financeiro" { + t.Errorf("folder %q", out.Folder) + } +} + +// A folder name is used to route mail, so it must survive intact - including +// the accents a Portuguese folder name has. +func TestFolderNamesKeepTheirAccents(t *testing.T) { + out := evaluate(t, `if header :contains "Subject" "x" { fileinto "Relatórios"; }`, + Message{Headers: map[string][]string{"subject": {"x"}}}) + if out.Folder != "Relatórios" { + t.Errorf("folder %q", out.Folder) + } +} + +// A script with thousands of rules must not become a way to stall delivery. +func TestEvaluationIsBounded(t *testing.T) { + var b strings.Builder + for i := 0; i < 5000; i++ { + b.WriteString(`if header :contains "Subject" "nada" { fileinto "X"; }` + "\n") + } + if _, err := Parse(b.String()); err == nil { + t.Error("an unreasonably large script should be refused rather than evaluated") + } +} + +// :matches is anchored: the pattern describes the whole value, not a piece of +// it. Without that, ":matches" would behave like ":contains" and a rule +// written to catch one exact address would quietly catch every address that +// contained it. +func TestMatchesIsAnchoredNotASubstring(t *testing.T) { + script := `if header :matches "Subject" "fatura" { fileinto "X"; }` + if out := evaluate(t, script, Message{Headers: map[string][]string{ + "subject": {"fatura"}, + }}); out.Folder != "X" { + t.Errorf("the exact value did not match: %q", out.Folder) + } + if out := evaluate(t, script, Message{Headers: map[string][]string{ + "subject": {"minha fatura de agosto"}, + }}); out.Folder != "" { + t.Errorf(":matches behaved like :contains: %q", out.Folder) + } +} + +// ? is exactly one character, which nothing else in the grammar provides. +func TestMatchesSingleCharacterWildcard(t *testing.T) { + script := `if header :matches "Subject" "fatura-?" { fileinto "X"; }` + if out := evaluate(t, script, Message{Headers: map[string][]string{ + "subject": {"fatura-1"}, + }}); out.Folder != "X" { + t.Errorf("? did not match one character: %q", out.Folder) + } + if out := evaluate(t, script, Message{Headers: map[string][]string{ + "subject": {"fatura-12"}, + }}); out.Folder != "" { + t.Errorf("? matched two characters: %q", out.Folder) + } + if out := evaluate(t, script, Message{Headers: map[string][]string{ + "subject": {"fatura-"}, + }}); out.Folder != "" { + t.Errorf("? matched no character at all: %q", out.Folder) + } +} + +// A star in the middle has to match the run between two fixed parts. +func TestMatchesStarInTheMiddle(t *testing.T) { + script := `if header :matches "Subject" "Relatório * de agosto" { fileinto "X"; }` + if out := evaluate(t, script, Message{Headers: map[string][]string{ + "subject": {"Relatório mensal de agosto"}, + }}); out.Folder != "X" { + t.Errorf("star in the middle did not match: %q", out.Folder) + } + if out := evaluate(t, script, Message{Headers: map[string][]string{ + "subject": {"Relatório mensal de julho"}, + }}); out.Folder != "" { + t.Errorf("matched despite a different tail: %q", out.Folder) + } +} + +// A pattern of stars against a long value is where a naive matcher goes +// exponential. It must stay fast. +func TestMatchesDoesNotBlowUpOnManyStars(t *testing.T) { + value := strings.Repeat("a", 3000) + "b" + script := `if header :matches "Subject" "` + strings.Repeat("*a", 20) + `*c" { fileinto "X"; }` + done := make(chan Outcome, 1) + go func() { + done <- evaluate(t, script, Message{Headers: map[string][]string{"subject": {value}}}) + }() + select { + case out := <-done: + if out.Folder != "" { + t.Errorf("matched what it should not: %q", out.Folder) + } + case <-time.After(3 * time.Second): + t.Fatal("wildcard matching took more than three seconds; it is backtracking exponentially") + } +} diff --git a/services/protocols/internal/domain/sieve/test.go b/services/protocols/internal/domain/sieve/test.go new file mode 100644 index 0000000..fa803ff --- /dev/null +++ b/services/protocols/internal/domain/sieve/test.go @@ -0,0 +1,213 @@ +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) + // 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) { + switch { + case pi < len(p) && (p[pi] == '?' || p[pi] == v[vi]): + vi++ + pi++ + case pi < len(p) && p[pi] == '*': + star = pi + mark = vi + pi++ + case star >= 0: + pi = star + 1 + mark++ + vi = mark + default: + return false + } + } + for pi < len(p) && p[pi] == '*' { + pi++ + } + 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 +} diff --git a/services/protocols/internal/domain/sieve/vacation.go b/services/protocols/internal/domain/sieve/vacation.go new file mode 100644 index 0000000..1601c7f --- /dev/null +++ b/services/protocols/internal/domain/sieve/vacation.go @@ -0,0 +1,60 @@ +package sieve + +import "strings" + +// shouldAutoReply decides whether a message may be answered automatically. +// +// This is the part of a vacation responder that matters. Replying to the wrong +// thing is how a mailbox answers a mailing list in front of everyone, or ends +// up in a loop with another autoresponder that replies to the reply. RFC 3834 +// is the rule: answer a message a person actually sent, and nothing else. +func shouldAutoReply(msg Message) bool { + // An empty envelope sender is a bounce or a notification. Replying to one + // sends mail to nobody, or worse, back into a loop. + if strings.TrimSpace(msg.Sender) == "" { + return false + } + + // Never answer yourself: the Sent copy and any self-addressed message. + if msg.Recipient != "" && strings.EqualFold(msg.Sender, msg.Recipient) { + return false + } + + local := strings.ToLower(msg.Sender) + if at := strings.Index(local, "@"); at > 0 { + local = local[:at] + } + // Addresses that exist to carry automated mail. Answering one is at best + // noise and at worst a loop. + for _, reserved := range []string{"mailer-daemon", "postmaster", "no-reply", "noreply", "bounce", "bounces"} { + if local == reserved || strings.HasPrefix(local, reserved+"+") { + return false + } + } + + // Headers that say, in one way or another, "this was not typed by a + // person" or "do not answer this". + if len(msg.header("auto-submitted")) > 0 { + for _, v := range msg.header("auto-submitted") { + if !strings.EqualFold(strings.TrimSpace(v), "no") { + return false + } + } + } + for _, header := range []string{ + "list-id", "list-help", "list-unsubscribe", "list-post", + "x-auto-response-suppress", "x-autoreply", "x-autorespond", + } { + if len(msg.header(header)) > 0 { + return false + } + } + for _, v := range msg.header("precedence") { + switch strings.ToLower(strings.TrimSpace(v)) { + case "bulk", "list", "junk": + return false + } + } + + return true +}