feat(regras): aplica as regras na entrega e envia a resposta de ausência

A metade que faltava. O motor já existia e nada o chamava; agora o
caminho de entrega consulta as regras da caixa que está a receber, e a
resposta de ausência é mesmo enviada.

Cada destinatário tem as suas próprias regras, avaliadas sobre a sua
própria cópia: duas caixas neste servidor que recebem a mesma mensagem
podem arquivá-la em sítios diferentes.

Nada disto pode impedir o correio de chegar. Um script que não faz
parse, ou uma base de dados que não responde, deixa a mensagem ser
entregue sem filtro em vez de a recusar - recusar diria ao remetente
que a mensagem falhou, e o servidor dele voltaria a tentar para sempre.
discard é aceitar e depois deitar fora, pela mesma razão: uma regra de
arquivo não é uma rejeição.

A resposta de ausência sai pela mesma fila, assinatura e limites de
envio que qualquer outra mensagem deste servidor - não é um caminho
especial - e só depois de a mensagem que a desencadeou estar guardada,
para não responder a correio que a caixa afinal não recebeu. Leva
Auto-Submitted: auto-replied, que é o que impede duas caixas ambas de
férias de se responderem uma à outra até uma esgotar a quota.

Pastas próprias. Só existiam as seis padrão, portanto uma regra que
dizia "arquiva isto em Faturas" nomeava uma pasta que não havia como
criar. Criar, renomear e apagar, com as reservadas protegidas: cada uma
delas significa alguma coisa para o protocolo ou para este servidor, e
deixar renomear a INBOX partia caminhos que nenhuma mensagem de erro
explica. Renomear não mexe nas mensagens nem nos UIDs, senão um cliente
IMAP perdia de vista tudo o que lá está dentro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-22 13:35:17 -03:00
1 parent b9ebf606e1
commit 42420a7de2
14 files changed
+1082 -6

No files matched your search

@@ -0,0 +1,158 @@
package usecase
import (
"context"
"errors"
"strings"
"unicode/utf8"
"lambdamail/protocols/internal/application/port"
)
// ManageFoldersUseCase lets a mailbox owner keep folders of their own.
//
// Only the six standard folders existed, so a rule that said "file this in
// Faturas" named a folder there was no way to create.
type ManageFoldersUseCase struct {
folders port.FolderAdmin
webmail port.WebmailRepository
}
func NewManageFoldersUseCase(folders port.FolderAdmin, webmail port.WebmailRepository) *ManageFoldersUseCase {
return &ManageFoldersUseCase{folders: folders, webmail: webmail}
}
var (
ErrInvalidFolderName = errors.New("folders: that name cannot be used")
ErrFolderExists = errors.New("folders: a folder with that name already exists")
ErrFolderReserved = errors.New("folders: that folder belongs to the mail system")
ErrFolderNotFound = errors.New("folders: no folder with that name")
)
// reservedFolders may not be created, renamed or deleted by a user.
//
// Each one means something to the protocol or to this server: INBOX is where
// delivery lands, Trash is where deleting puts things, Drafts and Sent record
// what a message is, and Reports is filled by the report ingestion. Letting a
// user rename one would break those paths in ways no error message explains.
var reservedFolders = map[string]bool{
"inbox": true, "sent": true, "drafts": true,
"trash": true, "junk": true, "archive": true, "reports": true,
}
// maxFolderNameLength keeps a name inside the column and inside what an IMAP
// client will show.
const maxFolderNameLength = 100
func (uc *ManageFoldersUseCase) Create(ctx context.Context, address, name string) error {
name = strings.TrimSpace(name)
if err := validateFolderName(name); err != nil {
return err
}
mailboxID, err := uc.mailbox(ctx, address)
if err != nil {
return err
}
existing, err := uc.folders.ListFolders(ctx, mailboxID)
if err != nil {
return err
}
if findFolder(existing, name) != nil {
return ErrFolderExists
}
return uc.folders.CreateFolder(ctx, mailboxID, name)
}
func (uc *ManageFoldersUseCase) Rename(ctx context.Context, address, from, to string) error {
from = strings.TrimSpace(from)
to = strings.TrimSpace(to)
if reservedFolders[strings.ToLower(from)] {
return ErrFolderReserved
}
if err := validateFolderName(to); err != nil {
return err
}
mailboxID, err := uc.mailbox(ctx, address)
if err != nil {
return err
}
existing, err := uc.folders.ListFolders(ctx, mailboxID)
if err != nil {
return err
}
if findFolder(existing, from) == nil {
return ErrFolderNotFound
}
if findFolder(existing, to) != nil {
return ErrFolderExists
}
return uc.folders.RenameFolder(ctx, mailboxID, from, to)
}
// Delete removes a folder the user created.
//
// The messages inside it go with it, which is why the interface has to say so
// before calling this.
func (uc *ManageFoldersUseCase) Delete(ctx context.Context, address, name string) error {
name = strings.TrimSpace(name)
if reservedFolders[strings.ToLower(name)] {
return ErrFolderReserved
}
mailboxID, err := uc.mailbox(ctx, address)
if err != nil {
return err
}
existing, err := uc.folders.ListFolders(ctx, mailboxID)
if err != nil {
return err
}
if findFolder(existing, name) == nil {
return ErrFolderNotFound
}
return uc.folders.DeleteFolder(ctx, mailboxID, name)
}
func (uc *ManageFoldersUseCase) mailbox(ctx context.Context, address string) (string, error) {
id, err := uc.webmail.FindMailboxIDByAddress(ctx, address)
if err != nil || id == "" {
return "", ErrNoSuchMailbox
}
return id, nil
}
func findFolder(folders []port.WebmailFolder, name string) *port.WebmailFolder {
for i, folder := range folders {
if strings.EqualFold(strings.TrimSpace(folder.Name), name) {
return &folders[i]
}
}
return nil
}
// validateFolderName rejects names that would confuse IMAP or the routing.
func validateFolderName(name string) error {
if strings.TrimSpace(name) == "" {
return ErrInvalidFolderName
}
if utf8.RuneCountInString(name) > maxFolderNameLength {
return ErrInvalidFolderName
}
if reservedFolders[strings.ToLower(name)] {
return ErrFolderReserved
}
// A delimiter would read as a nested folder to an IMAP client, and this
// server does not create hierarchies.
if strings.ContainsAny(name, `/\.`) {
return ErrInvalidFolderName
}
// Control characters have no place in a name that travels over IMAP.
for _, r := range name {
if r < 0x20 || r == 0x7f {
return ErrInvalidFolderName
}
}
return nil
}