#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,22 @@
package port
import "context"
// SieveScriptRecord represents a persistent Sieve script row in PostgreSQL.
type SieveScriptRecord struct {
ID string
MailboxID string
Name string
Script string
IsActive bool
}
// SieveRepository defines data operations for Sieve script management.
type SieveRepository interface {
GetScript(ctx context.Context, mailboxID string, name string) (*SieveScriptRecord, error)
PutScript(ctx context.Context, mailboxID string, name string, script string) error
ListScripts(ctx context.Context, mailboxID string) ([]SieveScriptRecord, error)
SetActiveScript(ctx context.Context, mailboxID string, name string) error
DeleteScript(ctx context.Context, mailboxID string, name string) error
RenameScript(ctx context.Context, mailboxID string, oldName string, newName string) error
}
@@ -0,0 +1,127 @@
package usecase
import (
"context"
"errors"
"fmt"
"github.com/alexedwards/argon2id"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/entity"
)
var (
ErrScriptNotFound = errors.New("script does not exist")
ErrActiveScriptDelete = errors.New("cannot delete active script")
ErrScriptAlreadyExists = errors.New("script with new name already exists")
ErrScriptQuotaExceeded = errors.New("script size exceeds storage limit")
)
const MaxScriptSizeBytes = 1024 * 1024 // 1 MB limit per Sieve script
type ManageSieveSessionUseCase struct {
authRepo port.AuthRepository
sieveRepo port.SieveRepository
}
func NewManageSieveSessionUseCase(authRepo port.AuthRepository, sieveRepo port.SieveRepository) *ManageSieveSessionUseCase {
return &ManageSieveSessionUseCase{
authRepo: authRepo,
sieveRepo: sieveRepo,
}
}
func (uc *ManageSieveSessionUseCase) Login(ctx context.Context, address, password string) (string, error) {
rec, err := uc.authRepo.FindByAddress(ctx, address)
if err != nil {
return "", fmt.Errorf("find mailbox: %w", err)
}
if rec == nil {
return "", ErrAuthFailed
}
match, err := argon2id.ComparePasswordAndHash(password, rec.PasswordHash)
if err != nil || !match {
return "", ErrAuthFailed
}
return rec.ID.String(), nil
}
func (uc *ManageSieveSessionUseCase) PutScript(ctx context.Context, mailboxID, name, content string) error {
if len(content) > MaxScriptSizeBytes {
return ErrScriptQuotaExceeded
}
if err := entity.ValidateSieveScript(content); err != nil {
return fmt.Errorf("invalid sieve script syntax: %w", err)
}
return uc.sieveRepo.PutScript(ctx, mailboxID, name, content)
}
func (uc *ManageSieveSessionUseCase) GetScript(ctx context.Context, mailboxID, name string) (*port.SieveScriptRecord, error) {
rec, err := uc.sieveRepo.GetScript(ctx, mailboxID, name)
if err != nil {
return nil, err
}
if rec == nil {
return nil, ErrScriptNotFound
}
return rec, nil
}
func (uc *ManageSieveSessionUseCase) ListScripts(ctx context.Context, mailboxID string) ([]port.SieveScriptRecord, error) {
return uc.sieveRepo.ListScripts(ctx, mailboxID)
}
func (uc *ManageSieveSessionUseCase) SetActive(ctx context.Context, mailboxID, name string) error {
if name != "" {
rec, err := uc.sieveRepo.GetScript(ctx, mailboxID, name)
if err != nil {
return err
}
if rec == nil {
return ErrScriptNotFound
}
}
return uc.sieveRepo.SetActiveScript(ctx, mailboxID, name)
}
func (uc *ManageSieveSessionUseCase) DeleteScript(ctx context.Context, mailboxID, name string) error {
rec, err := uc.sieveRepo.GetScript(ctx, mailboxID, name)
if err != nil {
return err
}
if rec == nil {
return ErrScriptNotFound
}
if rec.IsActive {
return ErrActiveScriptDelete
}
return uc.sieveRepo.DeleteScript(ctx, mailboxID, name)
}
func (uc *ManageSieveSessionUseCase) RenameScript(ctx context.Context, mailboxID, oldName, newName string) error {
oldRec, err := uc.sieveRepo.GetScript(ctx, mailboxID, oldName)
if err != nil {
return err
}
if oldRec == nil {
return ErrScriptNotFound
}
newRec, err := uc.sieveRepo.GetScript(ctx, mailboxID, newName)
if err != nil {
return err
}
if newRec != nil {
return ErrScriptAlreadyExists
}
return uc.sieveRepo.RenameScript(ctx, mailboxID, oldName, newName)
}
func (uc *ManageSieveSessionUseCase) CheckScript(_ context.Context, content string) error {
if len(content) > MaxScriptSizeBytes {
return ErrScriptQuotaExceeded
}
return entity.ValidateSieveScript(content)
}
@@ -0,0 +1,150 @@
package usecase
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
)
type fakeSieveRepository struct {
scripts map[string]map[string]port.SieveScriptRecord // mailboxID -> name -> record
}
func newFakeSieveRepository() *fakeSieveRepository {
return &fakeSieveRepository{
scripts: make(map[string]map[string]port.SieveScriptRecord),
}
}
func (f *fakeSieveRepository) GetScript(_ context.Context, mailboxID, name string) (*port.SieveScriptRecord, error) {
mb, ok := f.scripts[mailboxID]
if !ok {
return nil, nil
}
rec, ok := mb[name]
if !ok {
return nil, nil
}
return &rec, nil
}
func (f *fakeSieveRepository) PutScript(_ context.Context, mailboxID, name, script string) error {
mb, ok := f.scripts[mailboxID]
if !ok {
mb = make(map[string]port.SieveScriptRecord)
f.scripts[mailboxID] = mb
}
existing, exists := mb[name]
isActive := false
if exists {
isActive = existing.IsActive
}
mb[name] = port.SieveScriptRecord{
ID: uuid.New().String(),
MailboxID: mailboxID,
Name: name,
Script: script,
IsActive: isActive,
}
return nil
}
func (f *fakeSieveRepository) ListScripts(_ context.Context, mailboxID string) ([]port.SieveScriptRecord, error) {
mb, ok := f.scripts[mailboxID]
if !ok {
return nil, nil
}
var out []port.SieveScriptRecord
for _, rec := range mb {
out = append(out, rec)
}
return out, nil
}
func (f *fakeSieveRepository) SetActiveScript(_ context.Context, mailboxID, name string) error {
mb, ok := f.scripts[mailboxID]
if !ok {
return nil
}
for k, rec := range mb {
rec.IsActive = (k == name)
mb[k] = rec
}
return nil
}
func (f *fakeSieveRepository) DeleteScript(_ context.Context, mailboxID, name string) error {
mb, ok := f.scripts[mailboxID]
if !ok {
return nil
}
delete(mb, name)
return nil
}
func (f *fakeSieveRepository) RenameScript(_ context.Context, mailboxID, oldName, newName string) error {
mb, ok := f.scripts[mailboxID]
if !ok {
return nil
}
rec := mb[oldName]
delete(mb, oldName)
rec.Name = newName
mb[newName] = rec
return nil
}
func TestManageSieveSessionUseCase_Put_Get_List_SetActive_Delete(t *testing.T) {
mailboxID := uuid.New()
auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{
"user@example.test": {ID: mailboxID, PasswordHash: testPasswordHash},
}}
sieveRepo := newFakeSieveRepository()
uc := NewManageSieveSessionUseCase(auth, sieveRepo)
ctx := context.Background()
// 1. Login
gotID, err := uc.Login(ctx, "user@example.test", testPassword)
if err != nil || gotID != mailboxID.String() {
t.Fatalf("Login failed: err=%v, gotID=%s", err, gotID)
}
// 2. PutScript valid
scriptContent := `require ["fileinto"]; if header :is "Subject" "Spam" { fileinto "Junk"; }`
if err := uc.PutScript(ctx, gotID, "main_rules", scriptContent); err != nil {
t.Fatalf("PutScript failed: %v", err)
}
// 3. PutScript invalid syntax
if err := uc.PutScript(ctx, gotID, "bad_rules", `if header "Subject" {`); err == nil {
t.Fatal("expected error for invalid Sieve syntax, got nil")
}
// 4. GetScript
rec, err := uc.GetScript(ctx, gotID, "main_rules")
if err != nil || rec == nil || rec.Script != scriptContent {
t.Fatalf("GetScript failed: rec=%+v, err=%v", rec, err)
}
// 5. SetActive
if err := uc.SetActive(ctx, gotID, "main_rules"); err != nil {
t.Fatalf("SetActive failed: %v", err)
}
// 6. Delete active script fails per RFC 5804 §2.7
if err := uc.DeleteScript(ctx, gotID, "main_rules"); !errors.Is(err, ErrActiveScriptDelete) {
t.Fatalf("expected ErrActiveScriptDelete, got %v", err)
}
// 7. Deactivate all and delete
if err := uc.SetActive(ctx, gotID, ""); err != nil {
t.Fatalf("SetActive empty failed: %v", err)
}
if err := uc.DeleteScript(ctx, gotID, "main_rules"); err != nil {
t.Fatalf("DeleteScript failed: %v", err)
}
}