#8: implement ManageSieve protocol server (RFC 5804)
This commit is contained in:
1 parent
5a7dcc002a
commit
0e6fc27bc1
13 files changed
+1634
No files matched your search
@@ -18,6 +18,7 @@ import (
|
|||||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||||
tlsprovider "lambdamail/protocols/internal/infrastructure/tls"
|
tlsprovider "lambdamail/protocols/internal/infrastructure/tls"
|
||||||
imappresentation "lambdamail/protocols/internal/presentation/imap"
|
imappresentation "lambdamail/protocols/internal/presentation/imap"
|
||||||
|
managesievepresentation "lambdamail/protocols/internal/presentation/managesieve"
|
||||||
pop3presentation "lambdamail/protocols/internal/presentation/pop3"
|
pop3presentation "lambdamail/protocols/internal/presentation/pop3"
|
||||||
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
|
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
|
||||||
)
|
)
|
||||||
@@ -122,6 +123,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
sieveRepo := postgres.NewSieveRepository(pool)
|
||||||
|
sieveUseCase := usecase.NewManageSieveSessionUseCase(authRepo, sieveRepo)
|
||||||
|
sieveServer := managesievepresentation.NewServer(":4190", sieveUseCase, certProvider)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("lambdamail-protocols ManageSieve listening on :4190")
|
||||||
|
if err := sieveServer.ListenAndServe(); err != nil {
|
||||||
|
log.Fatalf("managesieve serve: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
handler := health.Handler(func() error { return pool.Ping(ctx) })
|
handler := health.Handler(func() error { return pool.Ping(ctx) })
|
||||||
addr := ":8080"
|
addr := ":8080"
|
||||||
log.Printf("lambdamail-protocols health listening on %s", addr)
|
log.Printf("lambdamail-protocols health listening on %s", addr)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alexedwards/argon2id"
|
||||||
|
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||||
|
|
||||||
|
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||||
|
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||||
|
managesievepresentation "lambdamail/protocols/internal/presentation/managesieve"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManageSieveEndToEnd(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("e2e test skipped in -short mode")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
runtimeDir := t.TempDir()
|
||||||
|
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
|
||||||
|
Port(54333). // distinct port for ManageSieve e2e test execution
|
||||||
|
RuntimePath(runtimeDir).
|
||||||
|
StartTimeout(120 * time.Second))
|
||||||
|
if err := pg.Start(); err != nil {
|
||||||
|
t.Fatalf("start embedded postgres: %v", err)
|
||||||
|
}
|
||||||
|
defer pg.Stop()
|
||||||
|
|
||||||
|
dbURL := "postgres://postgres:postgres@localhost:54333/postgres?sslmode=disable"
|
||||||
|
pool, err := postgres.NewPool(ctx, dbURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
root := repoRoot(t)
|
||||||
|
sql, err := os.ReadFile(filepath.Join(root, "migrations", "0001_init_schema.up.sql"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read migration: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
||||||
|
t.Fatalf("apply migration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Seed domain & mailbox -----------------------------------------------
|
||||||
|
password := "correct horse battery staple"
|
||||||
|
hash, err := argon2id.CreateHash(password, argon2id.DefaultParams)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash password: %v", err)
|
||||||
|
}
|
||||||
|
domainID := "00000000-0000-0000-0000-000000000001"
|
||||||
|
mailboxID := "00000000-0000-0000-0000-000000000002"
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'example.test', 'example.test')`, domainID); err != nil {
|
||||||
|
t.Fatalf("seed domain: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'user', 'user@example.test', $3)`, mailboxID, domainID, hash); err != nil {
|
||||||
|
t.Fatalf("seed mailbox: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup ManageSieve Server
|
||||||
|
sieveUC := appusecase.NewManageSieveSessionUseCase(
|
||||||
|
postgres.NewAuthRepository(pool),
|
||||||
|
postgres.NewSieveRepository(pool),
|
||||||
|
)
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
srv := managesievepresentation.NewServer(ln.Addr().String(), sieveUC, nil)
|
||||||
|
go srv.Serve(ln)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Dial ManageSieve server
|
||||||
|
conn, err := net.Dial("tcp", ln.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial ManageSieve: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
writer := bufio.NewWriter(conn)
|
||||||
|
|
||||||
|
readLine := func() string {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read line: %v", err)
|
||||||
|
}
|
||||||
|
return strings.TrimRight(line, "\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sendCmd := func(cmd string) {
|
||||||
|
writer.WriteString(cmd + "\r\n")
|
||||||
|
writer.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Greeting
|
||||||
|
for {
|
||||||
|
l := readLine()
|
||||||
|
if strings.HasPrefix(l, "OK") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. CAPABILITY
|
||||||
|
sendCmd("CAPABILITY")
|
||||||
|
for {
|
||||||
|
l := readLine()
|
||||||
|
if strings.HasPrefix(l, "OK") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. AUTHENTICATE PLAIN
|
||||||
|
saslPayload := base64.StdEncoding.EncodeToString([]byte("\x00user@example.test\x00correct horse battery staple"))
|
||||||
|
sendCmd(fmt.Sprintf("AUTHENTICATE \"PLAIN\" %s", saslPayload))
|
||||||
|
if authResp := readLine(); !strings.HasPrefix(authResp, "OK") {
|
||||||
|
t.Fatalf("AUTH resp = %q, want OK", authResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. CHECKSCRIPT
|
||||||
|
scriptContent := `require ["fileinto"]; if header :is "Subject" "Spam" { fileinto "Junk"; }`
|
||||||
|
sendCmd(fmt.Sprintf("CHECKSCRIPT {%d+}\r\n%s", len(scriptContent), scriptContent))
|
||||||
|
if checkResp := readLine(); !strings.HasPrefix(checkResp, "OK") {
|
||||||
|
t.Fatalf("CHECKSCRIPT resp = %q", checkResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. PUTSCRIPT
|
||||||
|
sendCmd(fmt.Sprintf("PUTSCRIPT \"my_rules\" {%d+}\r\n%s", len(scriptContent), scriptContent))
|
||||||
|
if putResp := readLine(); !strings.HasPrefix(putResp, "OK") {
|
||||||
|
t.Fatalf("PUTSCRIPT resp = %q", putResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. GETSCRIPT
|
||||||
|
sendCmd("GETSCRIPT \"my_rules\"")
|
||||||
|
getLiteralHead := readLine()
|
||||||
|
if !strings.HasPrefix(getLiteralHead, "{") {
|
||||||
|
t.Fatalf("GETSCRIPT literal head = %q", getLiteralHead)
|
||||||
|
}
|
||||||
|
gotScript := readLine()
|
||||||
|
if gotScript != scriptContent {
|
||||||
|
t.Fatalf("GETSCRIPT content = %q, want %q", gotScript, scriptContent)
|
||||||
|
}
|
||||||
|
if getOk := readLine(); !strings.HasPrefix(getOk, "OK") {
|
||||||
|
t.Fatalf("GETSCRIPT ok = %q", getOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. LISTSCRIPTS
|
||||||
|
sendCmd("LISTSCRIPTS")
|
||||||
|
listLine := readLine()
|
||||||
|
if !strings.Contains(listLine, `"my_rules"`) {
|
||||||
|
t.Fatalf("LISTSCRIPTS line = %q", listLine)
|
||||||
|
}
|
||||||
|
if listOk := readLine(); !strings.HasPrefix(listOk, "OK") {
|
||||||
|
t.Fatalf("LISTSCRIPTS ok = %q", listOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. SETACTIVE
|
||||||
|
sendCmd("SETACTIVE \"my_rules\"")
|
||||||
|
if setActiveOk := readLine(); !strings.HasPrefix(setActiveOk, "OK") {
|
||||||
|
t.Fatalf("SETACTIVE ok = %q", setActiveOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify in DB that script is active
|
||||||
|
var isActive bool
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT is_active FROM sieve_scripts WHERE mailbox_id = $1 AND name = 'my_rules'`, mailboxID).Scan(&isActive); err != nil || !isActive {
|
||||||
|
t.Fatalf("expected is_active=true in DB, err=%v, isActive=%v", err, isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. RENAMESCRIPT
|
||||||
|
sendCmd("RENAMESCRIPT \"my_rules\" \"active_rules\"")
|
||||||
|
if renameOk := readLine(); !strings.HasPrefix(renameOk, "OK") {
|
||||||
|
t.Fatalf("RENAMESCRIPT ok = %q", renameOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. HAVESPACE
|
||||||
|
sendCmd("HAVESPACE \"active_rules\" 500")
|
||||||
|
if spaceOk := readLine(); !strings.HasPrefix(spaceOk, "OK") {
|
||||||
|
t.Fatalf("HAVESPACE ok = %q", spaceOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Deactivate & DELETE
|
||||||
|
sendCmd("SETACTIVE \"\"")
|
||||||
|
if deactOk := readLine(); !strings.HasPrefix(deactOk, "OK") {
|
||||||
|
t.Fatalf("deactivate ok = %q", deactOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendCmd("DELETESCRIPT \"active_rules\"")
|
||||||
|
if delOk := readLine(); !strings.HasPrefix(delOk, "OK") {
|
||||||
|
t.Fatalf("DELETESCRIPT ok = %q", delOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify in DB that script was deleted
|
||||||
|
var count int
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM sieve_scripts WHERE mailbox_id = $1`, mailboxID).Scan(&count); err != nil || count != 0 {
|
||||||
|
t.Fatalf("expected 0 scripts in DB, got count=%d, err=%v", count, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. LOGOUT
|
||||||
|
sendCmd("LOGOUT")
|
||||||
|
if logoutOk := readLine(); !strings.HasPrefix(logoutOk, "OK") {
|
||||||
|
t.Fatalf("LOGOUT ok = %q", logoutOk)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SieveRepository implements port.SieveRepository against PostgreSQL sieve_scripts table.
|
||||||
|
type SieveRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSieveRepository(pool *pgxpool.Pool) *SieveRepository {
|
||||||
|
return &SieveRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SieveRepository) GetScript(ctx context.Context, mailboxID string, name string) (*port.SieveScriptRecord, error) {
|
||||||
|
var rec port.SieveScriptRecord
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, mailbox_id, name, script, is_active
|
||||||
|
FROM sieve_scripts
|
||||||
|
WHERE mailbox_id = $1 AND name = $2
|
||||||
|
`, mailboxID, name).Scan(&rec.ID, &rec.MailboxID, &rec.Name, &rec.Script, &rec.IsActive)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("get sieve script %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return &rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SieveRepository) PutScript(ctx context.Context, mailboxID string, name string, script string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO sieve_scripts (mailbox_id, name, script, is_active)
|
||||||
|
VALUES ($1, $2, $3, false)
|
||||||
|
ON CONFLICT (mailbox_id, name) DO UPDATE SET script = EXCLUDED.script
|
||||||
|
`, mailboxID, name, script)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("put sieve script %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SieveRepository) ListScripts(ctx context.Context, mailboxID string) ([]port.SieveScriptRecord, error) {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT id, mailbox_id, name, script, is_active
|
||||||
|
FROM sieve_scripts
|
||||||
|
WHERE mailbox_id = $1
|
||||||
|
ORDER BY name ASC
|
||||||
|
`, mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list sieve scripts: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []port.SieveScriptRecord
|
||||||
|
for rows.Next() {
|
||||||
|
var rec port.SieveScriptRecord
|
||||||
|
if err := rows.Scan(&rec.ID, &rec.MailboxID, &rec.Name, &rec.Script, &rec.IsActive); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SieveRepository) SetActiveScript(ctx context.Context, mailboxID string, name string) error {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE sieve_scripts SET is_active = false WHERE mailbox_id = $1
|
||||||
|
`, mailboxID); err != nil {
|
||||||
|
return fmt.Errorf("deactivate sieve scripts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if name != "" {
|
||||||
|
tag, err := tx.Exec(ctx, `
|
||||||
|
UPDATE sieve_scripts SET is_active = true WHERE mailbox_id = $1 AND name = $2
|
||||||
|
`, mailboxID, name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("activate sieve script %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return fmt.Errorf("script %s not found", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SieveRepository) DeleteScript(ctx context.Context, mailboxID string, name string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
DELETE FROM sieve_scripts WHERE mailbox_id = $1 AND name = $2 AND is_active = false
|
||||||
|
`, mailboxID, name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete sieve script %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SieveRepository) RenameScript(ctx context.Context, mailboxID string, oldName string, newName string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE sieve_scripts SET name = $3 WHERE mailbox_id = $1 AND name = $2
|
||||||
|
`, mailboxID, oldName, newName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("rename sieve script %s to %s: %w", oldName, newName, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSieveRepository_CRUD(t *testing.T) {
|
||||||
|
dbURL := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if dbURL == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL not set - skipping Postgres integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := NewPool(ctx, dbURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect pool: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
domainID := uuid.New()
|
||||||
|
mailboxID := uuid.New()
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'sieve.test', 'sieve.test')`, domainID); err != nil {
|
||||||
|
t.Fatalf("seed domain: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID)
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'sieve', 'sieve@sieve.test', 'hash')`, mailboxID, domainID); err != nil {
|
||||||
|
t.Fatalf("seed mailbox: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := NewSieveRepository(pool)
|
||||||
|
|
||||||
|
// 1. PutScript
|
||||||
|
if err := repo.PutScript(ctx, mailboxID.String(), "script1", "keep;"); err != nil {
|
||||||
|
t.Fatalf("PutScript: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. GetScript
|
||||||
|
rec, err := repo.GetScript(ctx, mailboxID.String(), "script1")
|
||||||
|
if err != nil || rec == nil || rec.Script != "keep;" {
|
||||||
|
t.Fatalf("GetScript: rec=%+v, err=%v", rec, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. SetActive
|
||||||
|
if err := repo.SetActiveScript(ctx, mailboxID.String(), "script1"); err != nil {
|
||||||
|
t.Fatalf("SetActiveScript: %v", err)
|
||||||
|
}
|
||||||
|
rec, _ = repo.GetScript(ctx, mailboxID.String(), "script1")
|
||||||
|
if !rec.IsActive {
|
||||||
|
t.Fatal("expected is_active = true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. RenameScript
|
||||||
|
if err := repo.RenameScript(ctx, mailboxID.String(), "script1", "script1_renamed"); err != nil {
|
||||||
|
t.Fatalf("RenameScript: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Deactivate and Delete
|
||||||
|
if err := repo.SetActiveScript(ctx, mailboxID.String(), ""); err != nil {
|
||||||
|
t.Fatalf("SetActiveScript empty: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.DeleteScript(ctx, mailboxID.String(), "script1_renamed"); err != nil {
|
||||||
|
t.Fatalf("DeleteScript: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Confirm deleted
|
||||||
|
rec, _ = repo.GetScript(ctx, mailboxID.String(), "script1_renamed")
|
||||||
|
if rec != nil {
|
||||||
|
t.Fatal("expected script to be deleted, got non-nil record")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
package managesievepresentation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
type state int
|
||||||
|
|
||||||
|
const (
|
||||||
|
stateAuth state = iota
|
||||||
|
stateMain
|
||||||
|
)
|
||||||
|
|
||||||
|
type conn struct {
|
||||||
|
rawConn net.Conn
|
||||||
|
reader *bufio.Reader
|
||||||
|
writer *bufio.Writer
|
||||||
|
tlsProvider port.CertProvider
|
||||||
|
|
||||||
|
useCase *appusecase.ManageSieveSessionUseCase
|
||||||
|
|
||||||
|
state state
|
||||||
|
username string
|
||||||
|
mailboxID string
|
||||||
|
isTLS bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConn(c net.Conn, useCase *appusecase.ManageSieveSessionUseCase, tlsProvider port.CertProvider) *conn {
|
||||||
|
return &conn{
|
||||||
|
rawConn: c,
|
||||||
|
reader: bufio.NewReader(c),
|
||||||
|
writer: bufio.NewWriter(c),
|
||||||
|
useCase: useCase,
|
||||||
|
tlsProvider: tlsProvider,
|
||||||
|
state: stateAuth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) serve() {
|
||||||
|
defer c.rawConn.Close()
|
||||||
|
|
||||||
|
c.sendCapabilities()
|
||||||
|
c.writeLine(`OK "LambdaMail ManageSieve ready."`)
|
||||||
|
|
||||||
|
for {
|
||||||
|
line, err := c.reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(line, " ", 2)
|
||||||
|
cmd := strings.ToUpper(parts[0])
|
||||||
|
arg := ""
|
||||||
|
if len(parts) > 1 {
|
||||||
|
arg = parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "CAPABILITY":
|
||||||
|
c.handleCapability()
|
||||||
|
case "STARTTLS":
|
||||||
|
if c.handleStartTLS() {
|
||||||
|
c.sendCapabilities()
|
||||||
|
c.writeLine(`OK "TLS negotiation complete."`)
|
||||||
|
}
|
||||||
|
case "AUTHENTICATE":
|
||||||
|
c.handleAuthenticate(arg)
|
||||||
|
case "PUTSCRIPT":
|
||||||
|
c.handlePutScript(arg)
|
||||||
|
case "GETSCRIPT":
|
||||||
|
c.handleGetScript(arg)
|
||||||
|
case "SETACTIVE":
|
||||||
|
c.handleSetActive(arg)
|
||||||
|
case "HAVESPACE":
|
||||||
|
c.handleHaveSpace(arg)
|
||||||
|
case "LISTSCRIPTS":
|
||||||
|
c.handleListScripts()
|
||||||
|
case "DELETESCRIPT":
|
||||||
|
c.handleDeleteScript(arg)
|
||||||
|
case "RENAMESCRIPT":
|
||||||
|
c.handleRenameScript(arg)
|
||||||
|
case "CHECKSCRIPT":
|
||||||
|
c.handleCheckScript(arg)
|
||||||
|
case "LOGOUT", "QUIT":
|
||||||
|
c.writeLine(`OK "Logout complete."`)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
c.writeLine(`NO "Unknown or unsupported command."`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) writeLine(msg string) error {
|
||||||
|
if _, err := c.writer.WriteString(msg + "\r\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.writer.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) sendCapabilities() {
|
||||||
|
c.writer.WriteString("\"IMPLEMENTATION\" \"LambdaMail ManageSieve\"\r\n")
|
||||||
|
c.writer.WriteString("\"SASL\" \"PLAIN\"\r\n")
|
||||||
|
if !c.isTLS && c.tlsProvider != nil {
|
||||||
|
c.writer.WriteString("\"STARTTLS\"\r\n")
|
||||||
|
}
|
||||||
|
c.writer.WriteString("\"SIEVE\" \"fileinto reject envelope subaddress body imap4flags variables\"\r\n")
|
||||||
|
c.writer.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) handleCapability() {
|
||||||
|
c.sendCapabilities()
|
||||||
|
c.writeLine(`OK "Capability completed."`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) handleStartTLS() bool {
|
||||||
|
if c.isTLS {
|
||||||
|
c.writeLine(`NO "TLS already active."`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if c.tlsProvider == nil {
|
||||||
|
c.writeLine(`NO "STARTTLS not supported."`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := c.writeLine(`OK "Begin TLS negotiation."`); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
GetCertificate: c.tlsProvider.GetCertificate,
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
}
|
||||||
|
tlsConn := tls.Server(c.rawConn, tlsConfig)
|
||||||
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c.rawConn = tlsConn
|
||||||
|
c.reader = bufio.NewReader(tlsConn)
|
||||||
|
c.writer = bufio.NewWriter(tlsConn)
|
||||||
|
c.isTLS = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) handleAuthenticate(arg string) {
|
||||||
|
if c.state != stateAuth {
|
||||||
|
c.writeLine(`NO "Already authenticated."`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Fields(arg)
|
||||||
|
if len(parts) == 0 || strings.ToUpper(parts[0]) != `"PLAIN"` && strings.ToUpper(parts[0]) != `PLAIN` {
|
||||||
|
c.writeLine(`NO "Unsupported SASL mechanism."`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var respB64 string
|
||||||
|
if len(parts) > 1 {
|
||||||
|
respB64 = parts[1]
|
||||||
|
} else {
|
||||||
|
// Challenge response mode
|
||||||
|
c.writeLine(`""`)
|
||||||
|
line, err := c.reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respB64 = strings.TrimRight(line, "\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(respB64)
|
||||||
|
if err != nil {
|
||||||
|
c.writeLine(`NO "Invalid base64 payload."`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SASL PLAIN format: [authzid] \0 authcid \0 passwd
|
||||||
|
saslParts := bytes.Split(decoded, []byte{0})
|
||||||
|
var user, pass string
|
||||||
|
if len(saslParts) == 3 {
|
||||||
|
user = string(saslParts[1])
|
||||||
|
pass = string(saslParts[2])
|
||||||
|
} else if len(saslParts) == 2 {
|
||||||
|
user = string(saslParts[0])
|
||||||
|
pass = string(saslParts[1])
|
||||||
|
} else {
|
||||||
|
c.writeLine(`NO "Malformed SASL PLAIN authentication."`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
mailboxID, err := c.useCase.Login(ctx, user, pass)
|
||||||
|
if err != nil {
|
||||||
|
c.writeLine(`NO "Authentication failed."`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.username = user
|
||||||
|
c.mailboxID = mailboxID
|
||||||
|
c.state = stateMain
|
||||||
|
c.writeLine(`OK "Logged in."`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) handlePutScript(arg string) {
|
||||||
|
if c.state != stateMain {
|
||||||
|
c.writeLine(`NO "Authentication required."`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name, content, err := c.parseNameAndContent(arg)
|
||||||
|
if err != nil {
|
||||||
|
c.writeLine(fmt.Sprintf(`NO "%v"`, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := c.useCase.PutScript(ctx, c.mailboxID, name, content); err != nil {
|
||||||
|
c.writeLine(fmt.Sprintf(`NO "%v"`, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.writeLine(`OK "PUTSCRIPT completed."`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) handleGetScript(arg string) {
|
||||||
|
if c.state != stateMain {
|
||||||
|
c.writeLine(`NO "Authentication required."`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := c.parseStringLiteral(arg)
|
||||||
|
if name == "" {
|
||||||
|
c.writeLine(`NO "Missing script name."`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
rec, err := c.useCase.GetScript(ctx, c.mailboxID, name)
|
||||||
|
if err != nil || rec == nil {
|
||||||
|
c.writeLine(`NO "Script does not exist."`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.writer.WriteString(fmt.Sprintf("{%d+}\r\n%s\r\n", len(rec.Script), rec.Script))
|
||||||
|
c.writeLine(`OK "GETSCRIPT completed."`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) handleSetActive(arg string) {
|
||||||
|
if c.state != stateMain {
|
||||||
|
c.writeLine(`NO "Authentication required."`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := c.parseStringLiteral(arg)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := c.useCase.SetActive(ctx, c.mailboxID, name); err != nil {
|
||||||
|
c.writeLine(fmt.Sprintf(`NO "%v"`, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.writeLine(`OK "SETACTIVE completed."`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) handleHaveSpace(arg string) {
|
||||||
|
parts := strings.Fields(arg)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
c.writeLine(`NO "Missing arguments for HAVESPACE."`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
size, err := strconv.Atoi(parts[1])
|
||||||
|
if err != nil || size < 0 || size > appusecase.MaxScriptSizeBytes {
|
||||||
|
c.writeLine(`NO "Quota exceeded."`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.writeLine(`OK "Storage space available."`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) handleListScripts() {
|
||||||
|
if c.state != stateMain {
|
||||||
|
c.writeLine(`NO "Authentication required."`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
scripts, err := c.useCase.ListScripts(ctx, c.mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
c.writeLine(`NO "Unable to list scripts."`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range scripts {
|
||||||
|
if s.IsActive {
|
||||||
|
c.writer.WriteString(fmt.Sprintf("%q ACTIVE\r\n", s.Name))
|
||||||
|
} else {
|
||||||
|
c.writer.WriteString(fmt.Sprintf("%q\r\n", s.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.writeLine(`OK "LISTSCRIPTS completed."`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) handleDeleteScript(arg string) {
|
||||||
|
if c.state != stateMain {
|
||||||
|
c.writeLine(`NO "Authentication required."`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := c.parseStringLiteral(arg)
|
||||||
|
if name == "" {
|
||||||
|
c.writeLine(`NO "Missing script name."`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := c.useCase.DeleteScript(ctx, c.mailboxID, name); err != nil {
|
||||||
|
c.writeLine(fmt.Sprintf(`NO "%v"`, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.writeLine(`OK "DELETESCRIPT completed."`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) handleRenameScript(arg string) {
|
||||||
|
if c.state != stateMain {
|
||||||
|
c.writeLine(`NO "Authentication required."`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := c.parseTwoStrings(arg)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
c.writeLine(`NO "Missing script names for RENAMESCRIPT."`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := c.useCase.RenameScript(ctx, c.mailboxID, parts[0], parts[1]); err != nil {
|
||||||
|
c.writeLine(fmt.Sprintf(`NO "%v"`, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.writeLine(`OK "RENAMESCRIPT completed."`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) handleCheckScript(arg string) {
|
||||||
|
var content string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if strings.HasPrefix(arg, "{") {
|
||||||
|
_, content, err = c.parseNameAndContent(`"" ` + arg)
|
||||||
|
} else {
|
||||||
|
content = c.parseStringLiteral(arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.writeLine(fmt.Sprintf(`NO "%v"`, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := c.useCase.CheckScript(ctx, content); err != nil {
|
||||||
|
c.writeLine(fmt.Sprintf(`NO "%v"`, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.writeLine(`OK "CHECKSCRIPT completed."`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) parseStringLiteral(input string) string {
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
if strings.HasPrefix(input, `"`) && strings.HasSuffix(input, `"`) && len(input) >= 2 {
|
||||||
|
return input[1 : len(input)-1]
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) parseTwoStrings(input string) []string {
|
||||||
|
fields := strings.Fields(input)
|
||||||
|
var out []string
|
||||||
|
for _, f := range fields {
|
||||||
|
out = append(out, c.parseStringLiteral(f))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *conn) parseNameAndContent(arg string) (string, string, error) {
|
||||||
|
parts := strings.SplitN(arg, " ", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return "", "", fmt.Errorf("missing parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := c.parseStringLiteral(parts[0])
|
||||||
|
literalHeader := strings.TrimSpace(parts[1])
|
||||||
|
|
||||||
|
if !strings.HasPrefix(literalHeader, "{") || !strings.HasSuffix(literalHeader, "}") {
|
||||||
|
// Not literal string - treat literalHeader as quoted string
|
||||||
|
return name, c.parseStringLiteral(literalHeader), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sizeStr := strings.TrimPrefix(literalHeader, "{")
|
||||||
|
sizeStr = strings.TrimSuffix(sizeStr, "}")
|
||||||
|
sizeStr = strings.TrimSuffix(sizeStr, "+")
|
||||||
|
|
||||||
|
size, err := strconv.Atoi(sizeStr)
|
||||||
|
if err != nil || size < 0 {
|
||||||
|
return "", "", fmt.Errorf("invalid literal size")
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, size)
|
||||||
|
if _, err := io.ReadFull(c.reader, buf); err != nil {
|
||||||
|
return "", "", fmt.Errorf("unable to read script content: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume trailing CRLF
|
||||||
|
c.reader.ReadString('\n')
|
||||||
|
|
||||||
|
return name, string(buf), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package managesievepresentation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/alexedwards/argon2id"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeAuthRepository struct {
|
||||||
|
byAddress map[string]port.MailboxAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAuthRepository) FindByAddress(_ context.Context, address string) (*port.MailboxAuth, error) {
|
||||||
|
rec, ok := f.byAddress[address]
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeSieveRepository struct {
|
||||||
|
scripts map[string]map[string]port.SieveScriptRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
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 setupManageSieveServer(t *testing.T) (net.Listener, func()) {
|
||||||
|
t.Helper()
|
||||||
|
password := "correct horse battery staple"
|
||||||
|
hash, err := argon2id.CreateHash(password, argon2id.DefaultParams)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash: %v", err)
|
||||||
|
}
|
||||||
|
mailboxID := uuid.New()
|
||||||
|
|
||||||
|
auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{
|
||||||
|
"user@example.test": {ID: mailboxID, PasswordHash: hash},
|
||||||
|
}}
|
||||||
|
sieveRepo := newFakeSieveRepository()
|
||||||
|
|
||||||
|
uc := appusecase.NewManageSieveSessionUseCase(auth, sieveRepo)
|
||||||
|
srv := NewServer("127.0.0.1:0", uc, nil)
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
go srv.Serve(ln)
|
||||||
|
|
||||||
|
return ln, func() {
|
||||||
|
ln.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManageSieveServer_FullLifecycle(t *testing.T) {
|
||||||
|
ln, cleanup := setupManageSieveServer(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
c, err := net.Dial("tcp", ln.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
reader := bufio.NewReader(c)
|
||||||
|
writer := bufio.NewWriter(c)
|
||||||
|
|
||||||
|
readLine := func() string {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
return strings.TrimRight(line, "\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sendCmd := func(cmd string) {
|
||||||
|
writer.WriteString(cmd + "\r\n")
|
||||||
|
writer.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Read greeting capabilities
|
||||||
|
for {
|
||||||
|
l := readLine()
|
||||||
|
if strings.HasPrefix(l, "OK") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. CAPABILITY
|
||||||
|
sendCmd("CAPABILITY")
|
||||||
|
for {
|
||||||
|
l := readLine()
|
||||||
|
if strings.HasPrefix(l, "OK") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. AUTHENTICATE PLAIN
|
||||||
|
saslPayload := base64.StdEncoding.EncodeToString([]byte("\x00user@example.test\x00correct horse battery staple"))
|
||||||
|
sendCmd(fmt.Sprintf("AUTHENTICATE \"PLAIN\" %s", saslPayload))
|
||||||
|
if authResp := readLine(); !strings.HasPrefix(authResp, "OK") {
|
||||||
|
t.Fatalf("AUTH resp = %q, want OK", authResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. CHECKSCRIPT
|
||||||
|
scriptContent := `require ["fileinto"]; if header :is "Subject" "Spam" { fileinto "Junk"; }`
|
||||||
|
sendCmd(fmt.Sprintf("CHECKSCRIPT {%d+}\r\n%s", len(scriptContent), scriptContent))
|
||||||
|
if checkResp := readLine(); !strings.HasPrefix(checkResp, "OK") {
|
||||||
|
t.Fatalf("CHECKSCRIPT resp = %q", checkResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. PUTSCRIPT
|
||||||
|
sendCmd(fmt.Sprintf("PUTSCRIPT \"my_rules\" {%d+}\r\n%s", len(scriptContent), scriptContent))
|
||||||
|
if putResp := readLine(); !strings.HasPrefix(putResp, "OK") {
|
||||||
|
t.Fatalf("PUTSCRIPT resp = %q", putResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. GETSCRIPT
|
||||||
|
sendCmd("GETSCRIPT \"my_rules\"")
|
||||||
|
getLiteralHead := readLine()
|
||||||
|
if !strings.HasPrefix(getLiteralHead, "{") {
|
||||||
|
t.Fatalf("GETSCRIPT literal head = %q", getLiteralHead)
|
||||||
|
}
|
||||||
|
getScriptContent := readLine()
|
||||||
|
if getScriptContent != scriptContent {
|
||||||
|
t.Errorf("GETSCRIPT content = %q, want %q", getScriptContent, scriptContent)
|
||||||
|
}
|
||||||
|
if getOk := readLine(); !strings.HasPrefix(getOk, "OK") {
|
||||||
|
t.Fatalf("GETSCRIPT term = %q", getOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. LISTSCRIPTS
|
||||||
|
sendCmd("LISTSCRIPTS")
|
||||||
|
listLine := readLine()
|
||||||
|
if !strings.Contains(listLine, `"my_rules"`) {
|
||||||
|
t.Fatalf("LISTSCRIPTS line = %q", listLine)
|
||||||
|
}
|
||||||
|
if listOk := readLine(); !strings.HasPrefix(listOk, "OK") {
|
||||||
|
t.Fatalf("LISTSCRIPTS term = %q", listOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. SETACTIVE
|
||||||
|
sendCmd("SETACTIVE \"my_rules\"")
|
||||||
|
if setActiveOk := readLine(); !strings.HasPrefix(setActiveOk, "OK") {
|
||||||
|
t.Fatalf("SETACTIVE term = %q", setActiveOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. LOGOUT
|
||||||
|
sendCmd("LOGOUT")
|
||||||
|
if logoutOk := readLine(); !strings.HasPrefix(logoutOk, "OK") {
|
||||||
|
t.Fatalf("LOGOUT term = %q", logoutOk)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package managesievepresentation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
addr string
|
||||||
|
useCase *appusecase.ManageSieveSessionUseCase
|
||||||
|
tlsProvider port.CertProvider
|
||||||
|
listener net.Listener
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(addr string, useCase *appusecase.ManageSieveSessionUseCase, tlsProvider port.CertProvider) *Server {
|
||||||
|
return &Server{
|
||||||
|
addr: addr,
|
||||||
|
useCase: useCase,
|
||||||
|
tlsProvider: tlsProvider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ListenAndServe() error {
|
||||||
|
ln, err := net.Listen("tcp", s.addr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.listener = ln
|
||||||
|
return s.Serve(ln)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Serve(ln net.Listener) error {
|
||||||
|
s.listener = ln
|
||||||
|
for {
|
||||||
|
c, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
go func(rawConn net.Conn) {
|
||||||
|
connHandler := newConn(rawConn, s.useCase, s.tlsProvider)
|
||||||
|
connHandler.serve()
|
||||||
|
}(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Close() error {
|
||||||
|
if s.listener != nil {
|
||||||
|
return s.listener.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in new issue
Block a user