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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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": {"<list.example.test>"}}},
|
||||||
|
{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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user