#8: implement ManageSieve protocol server (RFC 5804)

This commit is contained in:
Lucas Rayan committed 2026-07-25 04:20:21 -03:00
1 parent 5a7dcc002a
commit 0e6fc27bc1
13 files changed
+1634

No files matched your search

@@ -0,0 +1,12 @@
package entity
import "github.com/google/uuid"
// SieveScript represents a user's Sieve filtering script entity.
type SieveScript struct {
ID uuid.UUID
MailboxID uuid.UUID
Name string
Script string
IsActive bool
}
@@ -0,0 +1,117 @@
package entity
import (
"errors"
"fmt"
"strings"
)
var (
ErrEmptyScript = errors.New("sieve script cannot be empty")
ErrUnmatchedBrace = errors.New("unmatched curly brace in sieve script")
ErrUnmatchedBracket = errors.New("unmatched square bracket in sieve script")
ErrUnterminatedQuote = errors.New("unterminated string literal in sieve script")
ErrUnterminatedComment = errors.New("unterminated comment block in sieve script")
)
// ValidateSieveScript performs structural syntax validation of an RFC 5228 Sieve script.
func ValidateSieveScript(script string) error {
trimmed := strings.TrimSpace(script)
if trimmed == "" {
return ErrEmptyScript
}
var braceCount int
var bracketCount int
inSingleQuote := false
inMultiComment := false
inString := false
runes := []rune(script)
n := len(runes)
for i := 0; i < n; i++ {
r := runes[i]
// Handle multi-line comment /* ... */
if inMultiComment {
if r == '*' && i+1 < n && runes[i+1] == '/' {
inMultiComment = false
i++
}
continue
}
// Handle string literal "..."
if inString {
if r == '\\' {
i++ // skip escaped char
continue
}
if r == '"' {
inString = false
}
continue
}
// Handle single line comment # ...
if inSingleQuote {
if r == '\n' {
inSingleQuote = false
}
continue
}
// Detect start of comment /*
if r == '/' && i+1 < n && runes[i+1] == '*' {
inMultiComment = true
i++
continue
}
// Detect start of comment #
if r == '#' {
inSingleQuote = true
continue
}
// Detect start of string "
if r == '"' {
inString = true
continue
}
// Track braces & brackets
switch r {
case '{':
braceCount++
case '}':
braceCount--
if braceCount < 0 {
return ErrUnmatchedBrace
}
case '[':
bracketCount++
case ']':
bracketCount--
if bracketCount < 0 {
return ErrUnmatchedBracket
}
}
}
if inMultiComment {
return ErrUnterminatedComment
}
if inString {
return ErrUnterminatedQuote
}
if braceCount != 0 {
return fmt.Errorf("%w: balance is %d", ErrUnmatchedBrace, braceCount)
}
if bracketCount != 0 {
return fmt.Errorf("%w: balance is %d", ErrUnmatchedBracket, bracketCount)
}
return nil
}
@@ -0,0 +1,59 @@
package entity
import (
"errors"
"testing"
)
func TestValidateSieveScript_ValidScripts(t *testing.T) {
validScripts := []string{
`require ["fileinto", "reject"];
if header :is "Subject" "Spam" {
fileinto "Junk";
stop;
}`,
`# Simple keep script
keep;`,
`/* Multi-line comment
testing Sieve script */
if size :over 1M {
discard;
} else {
keep;
}`,
}
for i, s := range validScripts {
if err := ValidateSieveScript(s); err != nil {
t.Errorf("script %d failed: %v", i, err)
}
}
}
func TestValidateSieveScript_InvalidScripts(t *testing.T) {
tests := []struct {
name string
script string
wantErr error
}{
{"empty", "", ErrEmptyScript},
{"whitespace", " \n\t ", ErrEmptyScript},
{"unmatched opening brace", `if true { keep;`, ErrUnmatchedBrace},
{"unmatched closing brace", `if true { keep; }}`, ErrUnmatchedBrace},
{"unmatched bracket", `require ["fileinto";`, ErrUnmatchedBracket},
{"unterminated string", `fileinto "Junk;`, ErrUnterminatedQuote},
{"unterminated comment", `/* comment without end`, ErrUnterminatedComment},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateSieveScript(tt.script)
if err == nil {
t.Fatalf("expected error for %s, got nil", tt.name)
}
if !errors.Is(err, tt.wantErr) {
t.Errorf("error = %v, want %v", err, tt.wantErr)
}
})
}
}